# Ad Revenue Source: https://setup.despia.com/analytics/appsflyer/ad-revenue Report impression-level ad revenue per network to AppsFlyer for LTV and ROAS reporting per acquisition channel. Report impression-level ad revenue back to AppsFlyer each time an ad is shown to the user. AppsFlyer attributes the revenue to the campaign that acquired the user, so you can see which channels drive the most revenue, not just the most installs. ## Installation ```bash npm theme={null} npm install despia-native ``` ```bash pnpm theme={null} pnpm add despia-native ``` ```bash yarn theme={null} yarn add despia-native ``` ```javascript theme={null} import despia from 'despia-native'; ``` ```html UMD theme={null} ``` ```html ESM theme={null} ``` *** ## log\_ad\_revenue Fire `log_ad_revenue` with the per-impression value your ad network reports. ```javascript theme={null} const metaRevenue = { ad_platform: "facebook", currency: "USD", revenue: 0.008, ad_type: "interstitial", ad_unit_id: "your_unit_id" } despia("appsflyer://log_ad_revenue?" + new URLSearchParams(metaRevenue).toString()) ``` ```javascript theme={null} const tiktokRevenue = { ad_platform: "tiktok", currency: "USD", revenue: 0.005, ad_type: "banner", ad_unit_id: "your_unit_id" } despia("appsflyer://log_ad_revenue?" + new URLSearchParams(tiktokRevenue).toString()) ``` ```javascript theme={null} const admobRevenue = { ad_platform: "admob", currency: "USD", revenue: 0.012, ad_type: "rewarded", ad_unit_id: "ca-app-pub-xxx/yyy" } despia("appsflyer://log_ad_revenue?" + new URLSearchParams(admobRevenue).toString()) ``` Ad network name. Accepted values: `"facebook"`, `"tiktok"`, `"admob"`. 3-character ISO 4217 currency code, e.g. `"USD"`, `"EUR"`, `"AED"`. Defaults to `"USD"` if omitted. Revenue amount for this single ad impression. No commas or currency signs. Type of ad served: `"banner"`, `"interstitial"`, `"rewarded"`, `"native"`. Your ad unit ID from the ad network. AdMob and Google revenue is reported to AppsFlyer under Google AdMob mediation. Facebook and TikTok revenue is reported with the platform name under custom mediation. Both appear correctly in AppsFlyer's ad revenue reporting. *** ## Best practices Log at impression level, one call per ad shown, with the eCPM-derived per-impression value your ad network reports. Do not batch or sum impressions in your web layer, AppsFlyer's LTV models expect individual impressions. Keep `ad_unit_id` stable per placement so you can break revenue down by placement later. *** ## Resources despia-native [support@despia.com](mailto:support@despia.com) # Attribution Source: https://setup.despia.com/analytics/appsflyer/attribution Read where every user came from across TikTok, Meta, Google, affiliate, or organic using data injected by Despia on every page load. Despia resolves attribution natively through the AppsFlyer SDK, caches it on the device, and injects it into your web layer on every page load. It is also re-injected the moment attribution resolves or updates (first install conversion, re-engagement, deep link click), so the values stay current without any polling. ## Installation ```bash npm theme={null} npm install despia-native ``` ```bash pnpm theme={null} pnpm add despia-native ``` ```bash yarn theme={null} yarn add despia-native ``` ```javascript theme={null} import despia from 'despia-native'; ``` ```html UMD theme={null} ``` ```html ESM theme={null} ``` *** ## Attribution Variables Three variables are injected automatically: ```javascript theme={null} despia.appsFlyerAttribution // full attribution object, or null despia.appsFlyerReferrer // short human-readable source string despia.appsFlyerUID // AppsFlyer device ID ``` On the very first launch of a fresh install, `appsFlyerAttribution` is `null` until AppsFlyer responds (typically within a few seconds, and on iOS after the App Tracking Transparency prompt is answered, which the SDK waits up to 60 seconds for). Once resolved, the data is persisted natively and is available immediately on every future cold start, even offline. *** ## appsFlyerReferrer The fastest way to branch on acquisition source. A single lowercase string resolved in this order: 1. `deep_link_value` from the attributed link, if present 2. `deep_link_sub1`, if present 3. A known ad network with an organic or paid suffix, one of: | Value | Meaning | | ---------------------------------- | ------------------------------------ | | `tiktok_ad` / `tiktok_organic` | TikTok or Pangle, paid or organic | | `facebook_ad` / `facebook_organic` | Meta, paid or organic | | `google_ad` / `google_organic` | Google Ads or AdMob, paid or organic | 4. `utm_source`, if present 5. The raw `media_source`, if present 6. `"organic"`, the default when nothing else matched ```javascript theme={null} switch (despia.appsFlyerReferrer) { case "tiktok_ad": showCreatorWelcome() break case "facebook_ad": showMetaOfferScreen() break default: showDefaultOnboarding() } ``` *** ## appsFlyerAttribution The full attribution object. All fields are optional strings unless noted. The attributed network, e.g. `"tiktok"`, `"facebook"`, `"google"`. Campaign name. Also mirrored to `c` for affiliate-style reads. Full campaign hierarchy for granular reporting. Additional network metadata when the partner provides it. Mapped from `af_sub1`. Use for affiliate and creator payout tracking. UTM parameters from the link. When absent, they fall back to `af_sub1` through `af_sub5` respectively. Affiliate-style aliases: `pid` falls back to `media_source`, `c` falls back to `campaign`. Deep link routing values from the OneLink. `deep_link_value` plus `deep_link_sub1` through `deep_link_sub10` are the fields AppsFlyer guarantees on a deferred first-launch resolution. See [Deep Linking](/analytics/appsflyer/deeplinking). Normalized to a top-level field on both platforms. `page_id` is the one custom OneLink param Despia lifts out of the raw payload for you. Any other custom key lives only under `raw`. `true` when AppsFlyer classified the install as organic (`af_status`). `"install"`, `"re-engagement"`, or `"re-attribution"`. `true` only on the very first launch after install. Same value as `despia.appsFlyerUID`. Campaign cost data when available (`cost_value` falls back to `af_cost_value`, `cost_currency` to `af_cost_currency`). Timestamps of the attributed click and the install. Every raw key and value AppsFlyer returned, as strings. Custom parameters you add to a OneLink (e.g. `creator_code`, `episode_id`) appear here. `page_id` appears here too but is also normalized to a top-level field. Read defensively. Field availability varies by network, link type, and platform. On Android the object currently mirrors the raw AppsFlyer payload (e.g. `af_status` instead of `is_organic`), so prefer keys that exist in the raw payload (`media_source`, `campaign`, `deep_link_value`, your custom params) when you need guaranteed cross-platform reads, and fall back through both shapes for the rest. ```json theme={null} { "media_source": "tiktokads_int", "campaign": "summer_2025", "campaign_id": "1234567890", "ad_set": "summer_collection_adset", "ad_set_id": "9876543210", "channel": "tiktok", "utm_source": "tiktok", "utm_medium": "paid", "utm_campaign": "summer_2025", "deep_link_value": "product", "deep_link_sub1": "alex123", "page_id": "sneaker_42", "is_organic": false, "install_type": "install", "is_first_launch": true, "apps_flyer_uid": "1a2b3c4d5e6f7g8h", "click_time": "2025-03-15 14:22:00", "install_time": "2025-03-15 14:23:45", "raw": { "page_id": "sneaker_42", "creator_code": "alex123" } } ``` *** ## appsFlyerUID The AppsFlyer device identifier. Send it to your backend to run server-to-server events or to join AppsFlyer raw-data exports with your own database. ```javascript theme={null} await fetch("/api/link-attribution", { method: "POST", body: JSON.stringify({ afUid: despia.appsFlyerUID }) }) ``` *** ## Request Attribution On Demand The injected variables are refreshed automatically, but you can also request them explicitly and await the result: ```javascript theme={null} const data = await despia("appsflyer://get_attribution", ["appsFlyerAttribution"]) console.log(data.appsFlyerAttribution) const uid = await despia("appsflyer://get_uid", ["appsFlyerUID"]) console.log(uid.appsFlyerUID) ``` *** ## Personalize Onboarding by Source Branch on the referrer for a quick check, and drop into the full attribution object when you need campaign or deep link detail. ```javascript theme={null} function pickOnboarding() { const attr = despia.appsFlyerAttribution if (attr?.deep_link_value === "offer") { return startOfferFlow(attr.page_id ?? attr.raw?.page_id) } if (despia.appsFlyerReferrer.startsWith("tiktok")) { return startVideoFirstOnboarding() } return startDefaultOnboarding() } ``` *** ## Resources despia-native [support@despia.com](mailto:support@despia.com) # Campaigns Source: https://setup.despia.com/analytics/appsflyer/campaigns Connect TikTok and Meta as integrated partners, create custom attribution links, and measure which campaigns drive the most valuable users. Connect your TikTok and Meta ad accounts to AppsFlyer for accurate install attribution, event postbacks, and ROAS reporting per campaign. Combined with conversion events from the [Events](/analytics/appsflyer/events) page, this gives you a full picture of which campaigns drive the most valuable users, not just the most installs. ## Installation ```bash npm theme={null} npm install despia-native ``` ```bash pnpm theme={null} pnpm add despia-native ``` ```bash yarn theme={null} yarn add despia-native ``` ```javascript theme={null} import despia from 'despia-native'; ``` ```html UMD theme={null} ``` ```html ESM theme={null} ``` *** ## Connect Partners in AppsFlyer For install attribution and event postbacks to flow to Meta and TikTok you need to connect them as integrated partners. In your AppsFlyer dashboard, go to **Configuration > Integrated Partners**. Find **Meta ads**, click **Edit**, enable the integration, and enter your Meta App ID. Under **In-App Events**, map `af_purchase`, `af_subscribe`, and any other conversion events you want Meta to optimize for. Find **TikTok for Business** and repeat the same process. Set event postbacks to **All media sources including organic** for the Install event. This ensures Meta and TikTok receive data for all installs, not just their own attributed ones. *** ## Custom Attribution Links Create campaign-specific OneLink URLs to track performance per campaign, ad set, or creative. Go to **Engage > OneLink Management** in your AppsFlyer dashboard. Select your OneLink template and click **Add Link**. Set the media source, campaign name, and any custom parameters like `deep_link_value` or `page_id`. Paste the generated link directly into your TikTok or Meta ad creative as the destination URL. ```text theme={null} https://yourapp.onelink.me/xxxx?pid=tiktokads_int&c=summer_2025&deep_link_value=product&page_id=sneaker_42 ``` To route users to the right page when they open the app from one of these links, see [Deep Linking](/analytics/appsflyer/deeplinking). *** ## Ad Revenue Tracking Report impression-level ad revenue back to AppsFlyer per platform. This enables LTV and ROAS reporting per acquisition channel, so you can see which campaigns drive the most revenue, not just the most installs. Use `log_ad_revenue` each time an ad is shown to the user. ```javascript theme={null} const metaRevenue = { ad_platform: "facebook", currency: "USD", revenue: 0.008, ad_type: "interstitial", ad_unit_id: "your_unit_id" } despia("appsflyer://log_ad_revenue?" + new URLSearchParams(metaRevenue).toString()) ``` ```javascript theme={null} const tiktokRevenue = { ad_platform: "tiktok", currency: "USD", revenue: 0.005, ad_type: "banner", ad_unit_id: "your_unit_id" } despia("appsflyer://log_ad_revenue?" + new URLSearchParams(tiktokRevenue).toString()) ``` ```javascript theme={null} const admobRevenue = { ad_platform: "admob", currency: "USD", revenue: 0.012, ad_type: "rewarded", ad_unit_id: "ca-app-pub-xxx/yyy" } despia("appsflyer://log_ad_revenue?" + new URLSearchParams(admobRevenue).toString()) ``` Ad network name. Accepted values: `"facebook"`, `"tiktok"`, `"admob"`. 3-character ISO 4217 currency code, e.g. `"USD"`, `"EUR"`, `"AED"`. Defaults to `"USD"` if omitted. Revenue amount for this ad impression. No commas or currency signs. Type of ad served. Accepted values: `"banner"`, `"interstitial"`, `"rewarded"`, `"native"`. Your ad unit ID from the ad network. For best practices on impression-level reporting, see the [Ad Revenue](/analytics/appsflyer/ad-revenue) page. *** ## Web-to-App Attribution If users discover your app through your website before installing, add the AppsFlyer Smart Banner to stitch the web visit to the app install. Without this, users who visit your site and later install the app appear as organic in AppsFlyer. Add this to your website ``: ```html theme={null} ``` Replace `YOUR_WEB_KEY` with the web key from your AppsFlyer dashboard under **Settings > Brand Bundles > Web dev key**. This is a web-only change, no native code changes are required. *** ## Frequently Asked Questions If you install the Meta SDK and TikTok SDK separately, each platform only sees its own data and will overclaim credit for the same install. AppsFlyer acts as a neutral middleman. It determines which channel actually gets credit using last-click attribution and reports back to each platform what it earned, so your ROAS numbers become trustworthy. No, and that is expected. Meta and TikTok use their own attribution models, which typically show higher install counts than AppsFlyer. AppsFlyer's numbers are more accurate for your own budget decisions. Use AppsFlyer to decide where to spend, and use the Meta and TikTok dashboards to manage creatives. Map `af_complete_registration`, `af_purchase`, and `af_subscribe` at minimum. These give the ad algorithms enough signal to find users likely to convert. If you only map installs, the algorithms optimize for install volume, which rarely correlates with revenue. *** ## Resources despia-native AppsFlyer Smart Banner SDK reference Meta ads event mapping guide [support@despia.com](mailto:support@despia.com) # Consent Source: https://setup.despia.com/analytics/appsflyer/consent Forward GDPR and DMA consent choices from your cookie banner to the native AppsFlyer SDK. Forward the user's cookie-banner choice to the AppsFlyer SDK's consent API for DMA compliance. The values control how AppsFlyer processes the user's data and shares it with ad partners. ## Installation ```bash npm theme={null} npm install despia-native ``` ```bash pnpm theme={null} pnpm add despia-native ``` ```bash yarn theme={null} yarn add despia-native ``` ```javascript theme={null} import despia from 'despia-native'; ``` ```html UMD theme={null} ``` ```html ESM theme={null} ``` *** ## set\_consent ```javascript theme={null} despia("appsflyer://set_consent?is_gdpr=true&has_consent=true") ``` ```javascript theme={null} despia("appsflyer://set_consent?is_gdpr=true&has_consent=false") ``` ```javascript theme={null} despia("appsflyer://set_consent?is_gdpr=false&has_consent=true") ``` Whether the user is subject to GDPR (EU, EEA, and UK users). The user's choice from your consent banner. On iOS this single flag drives both AppsFlyer consent dimensions: data usage and ads personalization. *** ## When to call it Consent applies to the current SDK session. If you already have a stored choice, forward it immediately on page load, otherwise forward it the moment the user answers your banner. Send consent before `set_user_id`, `set_email`, `set_phone`, and any `log_event` calls so nothing is processed against the user's choice. If the user updates their preference in your settings screen, call `set_consent` again with the new values. *** ## iOS: App Tracking Transparency Consent and ATT are separate layers, and Despia handles the second one for you. On iOS the AppsFlyer SDK waits up to 60 seconds for the user to answer the ATT prompt before finalizing install attribution. If the user denies ATT, AppsFlyer operates without the IDFA regardless of what you pass in `set_consent`. `set_consent` is about privacy regulation (GDPR and DMA). ATT is about the device identifier. You need both to be in order. Despia manages the ATT flow natively, your web layer only supplies `set_consent`. *** ## Resources despia-native [support@despia.com](mailto:support@despia.com) # Deep Linking Source: https://setup.despia.com/analytics/appsflyer/deeplinking Carry campaign, affiliate, invite, and content metadata from a link into the app on first launch, even when the user installs from the store first. Deep linking carries the context of a click into your app. When someone taps your ad, a creator's affiliate link, an invite, or a share of a specific product or episode, the data you attached to that link (a product ID, a creator code, an invite code, an episode ID, an offer) is delivered to your app when it opens. This is the whole point of deferred deep linking: the user does not have the app yet, taps the link, gets sent to the App Store or Play Store, installs, and opens, and the original context still arrives on that first launch. Instead of dumping them on a generic home screen, you open the exact product, creator page, episode, or offer they came for. Your app decides what happens with the data. Despia resolves the link natively and hands you the payload. It never navigates the WebView to a URL built from the link, so there is no 404 risk and no routing you are forced into. What you do with the metadata, redirect, fetch, or render in place, is entirely your app logic. ## Installation ```bash npm theme={null} npm install despia-native ``` ```bash pnpm theme={null} pnpm add despia-native ``` ```bash yarn theme={null} yarn add despia-native ``` ```javascript theme={null} import despia from 'despia-native'; ``` ```html UMD theme={null} ``` ```html ESM theme={null} ``` *** ## How it works ```mermaid theme={null} flowchart TD A["User taps ad, creator, invite, or share link"] --> B{"App installed?"} B -->|"No"| D["Sent to App Store / Play Store"] D --> E["Installs and opens the app for the first time"] B -->|"Yes"| C["Universal Link / App Link opens the app"] E --> F["AppsFlyer resolves the stored click natively"] C --> F F --> G["Despia delivers the payload to window.onAppsFlyerDeepLink"] G --> H["Your app reads the metadata and opens the right content"] ``` You attach the context you care about to a OneLink when you create it (which product, which creator, which invite code). AppsFlyer stores that click server-side. When the app opens, the SDK resolves it and Despia calls `window.onAppsFlyerDeepLink(payload)` with that data. On a fresh install this is the deferred case: the click was stored before the app existed on the device, and it is delivered on first launch. Despia keeps registered OneLink domains out of the WebView entirely, so the user never sees a redirect page and your app never has to parse a link. Deferred deep linking works even if the user installs days after clicking. AppsFlyer stores the click with a configurable lookback window (default 7 days) and delivers it on the first launch inside that window. *** ## What arrives in the app The callback receives a plain object: the data you put on the link, delivered intact. A typical creator campaign for a specific product looks like this: ```json theme={null} { "deep_link_value": "product", "page_id": "sneaker_42", "deep_link_sub1": "alex123", "media_source": "influencer_int", "campaign": "summer_2025", "is_first_launch": true, "af_status": "Non-organic" } ``` Two fields do the routing work, and the rest is context: `deep_link_value` tells you what kind of destination this is, for example `"product"`, `"collection"`, `"creator"`, `"episode"`, or `"offer"`. It is just a string you chose when you built the link. The `deep_link_sub1` through `deep_link_sub10` params carry the specifics, the product ID, the creator or affiliate code, the invite code, the episode ID. Along with `deep_link_value`, these ten sub params are the fields AppsFlyer guarantees on a deferred first-launch resolution, so put anything a fresh install must receive into them. You can also add your own named params like `page_id` or `episode_id`, and they arrive as top-level keys on the callback, but read the reliability note below before relying on them for deferred installs. On a deferred install, a brand-new user resolving through AppsFlyer Unified Deep Linking, only `deep_link_value` and `deep_link_sub1` through `deep_link_sub10` are guaranteed in the payload. Arbitrary custom keys are excluded from the UDL payload by design. Despia still surfaces them in practice, because it also forwards AppsFlyer's conversion-data payload on first launch and that payload carries your custom params for click-matched installs, but that is a fallback path, not the guaranteed one. For anything a fresh install must not lose, use `deep_link_sub1` through `deep_link_sub10`. On a deferred install both native paths can fire, so your handler may be called more than once. Keep it idempotent. The session-flag dedupe shown under Cold-start fallback covers this. *** ## Use the data You get the object, you do whatever your app needs. There is no required pattern. The three most common: Redirect to a page that loads its own content from the params: ```javascript theme={null} window.onAppsFlyerDeepLink = function (payload) { if (payload.deep_link_value) { // e.g. /product?id=sneaker_42&ref=alex123, and that page fetches on load const qs = new URLSearchParams({ id: payload.page_id, ref: payload.deep_link_sub1 }) window.location.assign("/" + payload.deep_link_value + "?" + qs) } } ``` Make the backend call right there and open the dynamic content, no navigation at all: ```javascript theme={null} window.onAppsFlyerDeepLink = async function (payload) { if (payload.deep_link_value === "product") { const product = await fetch("/api/products/" + payload.page_id).then(r => r.json()) openProductScreen(product) } if (payload.deep_link_value === "creator") { const creator = await fetch("/api/creators/" + payload.page_id).then(r => r.json()) openCreatorProfile(creator) } } ``` Apply an invite or affiliate code and drop the user into the referred experience: ```javascript theme={null} window.onAppsFlyerDeepLink = function (payload) { const creatorCode = payload.deep_link_sub1 // guaranteed on deferred installs const inviteCode = payload.deep_link_sub2 // keep critical codes in sub1-sub10 if (creatorCode) applyCreatorReferral(creatorCode) if (inviteCode) applyInvite(inviteCode) if (payload.page_id) openContent(payload.deep_link_value, payload.page_id) } ``` If your app already uses a client-side router, mapping `deep_link_value` to a route is one clean way to do the same thing, but it is an option, not a requirement: ```javascript theme={null} // Optional: router-based dispatch const ROUTES = { product: "/product", creator: "/creator", episode: "/episode", offer: "/offer" } window.onAppsFlyerDeepLink = function (payload) { const path = ROUTES[payload.deep_link_value] if (!path) return // unknown value, leave the user on the current screen navigate(path + "?id=" + payload.page_id) // your router's navigate } ``` Read the metadata from the callback payload, not from the page URL. The OneLink's query string is never appended to your app's own URL. *** ## Register the callback early Define `window.onAppsFlyerDeepLink` as early as possible, ideally in an inline script in your document ``, before your app bundle loads. Despia queues deep links that arrive before the page is ready and replays them roughly 300 ms after the page registers, so the handler must already exist by then. Assign it bare, outside any user agent gate, since setting a property on `window` is harmless when the app runs outside Despia. ```javascript theme={null} // index.html, before the bundle loads ``` ```javascript theme={null} // later, once your app and data layer are ready, drain the queue and take over function installDeepLinkHandler(handle) { if (window.__pendingDeepLink) { handle(window.__pendingDeepLink) window.__pendingDeepLink = null } window.onAppsFlyerDeepLink = handle } ``` *** ## When the callback fires | Scenario | What happens | | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Fresh install (deferred) | On first launch the stored click resolves and the callback fires. `deep_link_value` and `deep_link_sub1` through `deep_link_sub10` are guaranteed; custom named params arrive when the conversion-data fallback matches the click. This is the case deep linking exists for. | | App installed, link tapped | The link opens the app, AppsFlyer resolves it, and the callback fires with the click payload. Attribution updates to `install_type: "re-engagement"`. | | Paid install without deep\_link\_value | The callback still fires on first launch for non-organic installs, so you can personalize onboarding from `media_source` or `campaign` even when the ad carried no routing value. | | Organic install, no link | The callback does not fire. `despia.appsFlyerReferrer` is `"organic"`. | On iOS, deferred resolution happens after the App Tracking Transparency prompt is answered (the SDK waits up to 60 seconds for it), so on a fresh install the callback can arrive well after your first page render. Keep the handler installed, do not treat it as launch-time-only. *** ## Cold-start fallback The last resolved deep link also persists inside the injected attribution object. If your page reloads after the callback already fired, read it from there and de-duplicate with your own one-shot flag: ```javascript theme={null} const attr = despia.appsFlyerAttribution const dlv = attr?.deep_link_value if (dlv && sessionStorage.getItem("dl_handled") !== dlv) { sessionStorage.setItem("dl_handled", dlv) window.onAppsFlyerDeepLink(attr.raw ?? attr) } ``` *** ## Payload reference The kind of destination, chosen by you when creating the OneLink, e.g. `"product"`, `"creator"`, `"episode"`, `"offer"`. Keep values short and URL-safe. The specific content ID to load, e.g. a product ID, episode ID, or profile ID. A custom param, but a special-cased one: Despia also lifts `page_id` to a top-level normalized field on `despia.appsFlyerAttribution` (both platforms). Other custom keys only appear under `despia.appsFlyerAttribution.raw`. On the callback payload every key is top-level regardless. Ten general-purpose values from the OneLink, commonly a creator code, affiliate code, or invite code. Along with `deep_link_value`, these are the only fields AppsFlyer guarantees on a deferred first-launch resolution, so use them for anything a fresh install must receive. `deep_link_sub1` also doubles as the `appsFlyerReferrer` fallback when `deep_link_value` is absent, so set both together. Any parameter you add to the OneLink (`episode_id`, `season`, `ref`, ...) arrives as a top-level key on the callback payload, and under `despia.appsFlyerAttribution.raw` on the attribution object. Reliable on direct opens and re-engagements. On a deferred install they come through the conversion-data fallback rather than the guaranteed UDL payload, so move must-have IDs into `deep_link_sub1` through `deep_link_sub10`. The attributed network and campaign for this click. `true` when the payload comes from a deferred deep link on first launch. `"Organic"` or `"Non-organic"` (first-launch conversion payloads only). *** ## Creator and Affiliate Links Give each creator their own OneLink with a unique code in `deep_link_sub1` and the content they are promoting in `page_id`. When a user installs through that link, the code arrives on first launch, so you know exactly which creator drove the install and can credit them, apply their discount, and open the product they were promoting. ```text theme={null} https://yourapp.onelink.me/xxxx?pid=influencer_int&c=summer_2025&deep_link_value=product&page_id=sneaker_42&deep_link_sub1=alex123 ``` ```javascript theme={null} window.onAppsFlyerDeepLink = function (payload) { const creatorCode = payload.deep_link_sub1 // "alex123" const productId = payload.page_id // "sneaker_42" if (creatorCode) { showCreatorWelcome(creatorCode) applyCreatorDiscount(creatorCode) // report the referral back to AppsFlyer for commission tracking const referral = { af_sub1: creatorCode, af_content_id: productId } despia("appsflyer://log_event?event_name=creator_referral&event_values=" + encodeURIComponent(JSON.stringify(referral))) } if (productId) openProductScreen(productId) } ``` Filter raw data by `af_sub1` in your AppsFlyer dashboard to see installs, events, and revenue per creator. *** ## Create the OneLink In your AppsFlyer dashboard, go to **Engage > OneLink Management** and create a link. Attach a `deep_link_value` and put the IDs a fresh install must receive into `deep_link_sub1` through `deep_link_sub10`. `page_id` and other named params work too, but are only guaranteed on direct opens and re-engagements, see the reliability note above. Your OneLink domain (e.g. `yourapp.onelink.me`) must be registered in your Despia app build so the OS opens the app directly and Despia keeps the URL out of the WebView. Links from unregistered domains behave like ordinary web links. Paste the OneLink into your TikTok or Meta ad creative, or give it to a content creator as their affiliate link. Your `onAppsFlyerDeepLink` handler receives the metadata and opens the right content. That is the whole contract. Adding or changing OneLink domains and ad platform IDs in the Despia Editor requires a fresh native build. Until you rebuild and users install the new version, the OS will not open your app from those links and the metadata will not be delivered. *** ## Frequently Asked Questions To carry the context of a click into the app on first launch even though the user did not have the app when they clicked. They tap your ad or a creator link, install from the store, open the app, and the product, creator, invite, or episode they came for is available immediately. Without it, every install lands on a generic home screen and the campaign context is lost. No. You receive a plain object and do whatever you want with it, redirect, run a backend call and render the result, apply a code, or open a screen in place. A router is just one convenient way to dispatch on `deep_link_value` if your app already has one. The user did not come from a deep link campaign. Read `media_source` and `campaign` from the payload if you still want to personalize a paid install, otherwise show your default page. Yes. Named params (`episode_id`, `ref`, ...) arrive as top-level keys on the callback payload and under `despia.appsFlyerAttribution.raw`. One caveat for deferred installs: AppsFlyer's Unified Deep Linking payload only guarantees `deep_link_value` and `deep_link_sub1` through `deep_link_sub10` for a brand-new user. Custom keys still tend to arrive because Despia also forwards the conversion-data payload on first launch, but that is a fallback, so put anything a fresh install must have into `deep_link_sub1` through `deep_link_sub10`. *** ## Resources despia-native [support@despia.com](mailto:support@despia.com) # Events Source: https://setup.despia.com/analytics/appsflyer/events Log standard and custom in-app events from your web layer. Standard af_ events map automatically to Meta and TikTok conversion events. Events fired from your web layer are intercepted natively and logged through the AppsFlyer SDK in real time. Standard `af_` prefixed events map automatically to Meta and TikTok conversion events based on your dashboard partner configuration. Custom events appear only in AppsFlyer for funnel and retention analysis. `event_values` must always be a JSON stringified and URI encoded object. Always use `encodeURIComponent(JSON.stringify(...))`. Event names are case-sensitive, lowercase alphanumeric characters and underscores only. There is a limit of 300 unique event names per day. ## Installation ```bash npm theme={null} npm install despia-native ``` ```bash pnpm theme={null} pnpm add despia-native ``` ```bash yarn theme={null} yarn add despia-native ``` ```javascript theme={null} import despia from 'despia-native'; ``` ```html UMD theme={null} ``` ```html ESM theme={null} ``` *** ## The log\_event command Fire any in-app event by passing an event name and a URI-encoded JSON object of values. Pass `{}` when there are no values. ```javascript theme={null} const values = { af_registration_method: "email" } despia("appsflyer://log_event?event_name=af_complete_registration&event_values=" + encodeURIComponent(JSON.stringify(values))) ``` A standard `af_*` event or your own custom name. Lowercase alphanumeric and underscores only, case-sensitive. A URL-encoded JSON object of event parameters. Wrap with `encodeURIComponent(JSON.stringify(...))`. Keep values flat: strings, numbers, and booleans. *** ## Standard AF Events These events use the `af_` prefix and map automatically to Meta and TikTok conversion events. Use them for any action you want to optimize ad campaigns against. | Event | When to log it | Maps to | | -------------------------- | ------------------------------- | ---------------------------------------------------------- | | `af_complete_registration` | Account created | Meta `CompleteRegistration`, TikTok `CompleteRegistration` | | `af_login` | User logs in | Login | | `af_purchase` | Payment completed | Meta `Purchase`, TikTok `CompletePayment` | | `af_start_trial` | Free trial started | Meta `StartTrial`, TikTok `Subscribe` | | `af_subscribe` | Paid subscription started | Meta `Subscribe`, TikTok `Subscribe` | | `af_add_to_cart` | Item added to cart | Meta `AddToCart`, TikTok `AddToCart` | | `af_initiated_checkout` | Checkout started | Meta `InitiateCheckout`, TikTok `InitiateCheckout` | | `af_content_view` | Product or content page viewed | Meta `ViewContent`, TikTok `ViewContent` | | `af_search` | Search performed | Meta `Search`, TikTok `Search` | | `af_share` | Content shared | AppsFlyer only | | `af_invite` | Invite or referral sent | AppsFlyer only | | `af_tutorial_completion` | Onboarding or tutorial finished | Meta `CompleteTutorial` | | `af_level_achieved` | Progression milestone | Meta `AchieveLevel`, TikTok `AchieveLevel` | ### af\_complete\_registration Records when a user completes sign up. ```javascript theme={null} const registration = { af_registration_method: "email" } despia("appsflyer://log_event?event_name=af_complete_registration&event_values=" + encodeURIComponent(JSON.stringify(registration))) ``` Sign up method used, e.g. `"email"`, `"facebook"`, `"google"`, `"apple"`. ### af\_login Records when a user logs in. ```javascript theme={null} const login = { af_registration_method: "facebook" } despia("appsflyer://log_event?event_name=af_login&event_values=" + encodeURIComponent(JSON.stringify(login))) ``` Login method used, e.g. `"email"`, `"facebook"`, `"google"`, `"apple"`. ### af\_purchase Records a purchase and associates revenue. The most important event for ROAS optimization. ```javascript theme={null} const purchase = { af_revenue: 9.99, af_currency: "USD", af_content_id: "pro_plan", af_content_type: "subscription", af_quantity: 1, af_order_id: "order_abc123" } despia("appsflyer://log_event?event_name=af_purchase&event_values=" + encodeURIComponent(JSON.stringify(purchase))) ``` Total purchase amount, counted as real revenue in AppsFlyer. No commas, currency signs, or text. Up to 5 decimal places. Use negative values for refunds. 3-character ISO 4217 code, e.g. `"USD"`, `"EUR"`, `"AED"`. Defaults to `"USD"` if omitted. ID of the purchased item or product, e.g. `"pro_plan"`, `"sku_123"`. Category of the purchased item, e.g. `"subscription"`, `"course"`, `"item"`. Number of items purchased. Your internal order ID, used for deduplication. Do not send `af_revenue` if you have an ROI360 subscription and use the purchase connector. It causes duplicate revenue reporting in your dashboard. ### af\_start\_trial Records the start of a free trial. ```javascript theme={null} const trial = { af_price: 0, af_currency: "USD" } despia("appsflyer://log_event?event_name=af_start_trial&event_values=" + encodeURIComponent(JSON.stringify(trial))) ``` Price of the trial. Use `af_price`, not `af_revenue`. `af_price` is not counted as revenue and is the correct parameter for Meta's `StartTrial` mapping. 3-character ISO 4217 code. Defaults to `"USD"` if omitted. ### af\_subscribe Records a paid subscription purchase. ```javascript theme={null} const subscription = { af_price: 9.99, af_currency: "USD", af_content_id: "pro_monthly" } despia("appsflyer://log_event?event_name=af_subscribe&event_values=" + encodeURIComponent(JSON.stringify(subscription))) ``` Subscription price per period. Not counted as revenue. Add `af_revenue` if you also want to record this as revenue in AppsFlyer. 3-character ISO 4217 code. Defaults to `"USD"` if omitted. ID of the subscription plan, e.g. `"pro_monthly"`, `"annual_plan"`. ### af\_add\_to\_cart Records when a user adds an item to their cart. ```javascript theme={null} const addToCart = { af_content_id: "item_42", af_content_type: "clothing", af_price: 19.99, af_currency: "USD", af_quantity: 1 } despia("appsflyer://log_event?event_name=af_add_to_cart&event_values=" + encodeURIComponent(JSON.stringify(addToCart))) ``` ID of the item added to cart. Category of the item. Price of the individual item. Not counted as revenue. 3-character ISO 4217 code. Defaults to `"USD"` if omitted. Quantity added to cart. ### af\_initiated\_checkout Records when a user begins the checkout flow. ```javascript theme={null} const checkout = { af_revenue: 29.99, af_currency: "USD", af_num_items: 2, af_content_id: "cart_checkout" } despia("appsflyer://log_event?event_name=af_initiated_checkout&event_values=" + encodeURIComponent(JSON.stringify(checkout))) ``` Total cart value at checkout. Counted as revenue if included. 3-character ISO 4217 code. Defaults to `"USD"` if omitted. Total number of items in the cart. Identifier for the checkout session or cart. ### af\_content\_view Records when a user views a piece of content. ```javascript theme={null} const contentView = { af_content_id: "product_123", af_content_type: "product" } despia("appsflyer://log_event?event_name=af_content_view&event_values=" + encodeURIComponent(JSON.stringify(contentView))) ``` ID of the content viewed, e.g. a product ID, profile ID, or article ID. Category of the content, e.g. `"product"`, `"article"`, `"collection"`. Price associated with the content if applicable. 3-character ISO 4217 code. Defaults to `"USD"` if omitted. ### af\_search Records a search event. ```javascript theme={null} const search = { af_search_string: "running shoes" } despia("appsflyer://log_event?event_name=af_search&event_values=" + encodeURIComponent(JSON.stringify(search))) ``` The search query entered by the user. ### af\_share Records when a user shares content. ```javascript theme={null} const share = { channel: "instagram" } despia("appsflyer://log_event?event_name=af_share&event_values=" + encodeURIComponent(JSON.stringify(share))) ``` Platform shared to, e.g. `"instagram"`, `"whatsapp"`, `"twitter"`. ### af\_invite Records when a user sends an invite or referral. ```javascript theme={null} const invite = { channel: "sms" } despia("appsflyer://log_event?event_name=af_invite&event_values=" + encodeURIComponent(JSON.stringify(invite))) ``` Channel the invite was sent through, e.g. `"sms"`, `"email"`, `"whatsapp"`. ### af\_tutorial\_completion Records when a user completes a tutorial or onboarding step. ```javascript theme={null} const tutorial = { af_success: true, af_tutorial_name: "step_2" } despia("appsflyer://log_event?event_name=af_tutorial_completion&event_values=" + encodeURIComponent(JSON.stringify(tutorial))) ``` Whether the tutorial was completed successfully. Name or identifier of the tutorial step. ### af\_level\_achieved Records when a user reaches a new level. ```javascript theme={null} const level = { af_level: 5 } despia("appsflyer://log_event?event_name=af_level_achieved&event_values=" + encodeURIComponent(JSON.stringify(level))) ``` The level number achieved. *** ## Custom Events Custom events do not map to Meta or TikTok automatically, but appear in your AppsFlyer dashboard for funnel and retention analysis. Name them using lowercase alphanumeric characters and underscores, and keep the parameter set stable so dashboards stay queryable. ```javascript theme={null} const onboardingStep = { step: 1, name: "select_interests" } despia("appsflyer://log_event?event_name=onboarding_step&event_values=" + encodeURIComponent(JSON.stringify(onboardingStep))) ``` A few more examples, each fired the same way: ```javascript theme={null} despia("appsflyer://log_event?event_name=profile_created&event_values=" + encodeURIComponent(JSON.stringify({ method: "email" }))) despia("appsflyer://log_event?event_name=subscription_start&event_values=" + encodeURIComponent(JSON.stringify({ plan: "pro", trial: true, price: 9.99 }))) despia("appsflyer://log_event?event_name=referral_sent&event_values=" + encodeURIComponent(JSON.stringify({ code: "REF123", channel: "whatsapp" }))) despia("appsflyer://log_event?event_name=streak_achieved&event_values=" + encodeURIComponent(JSON.stringify({ days: 7 }))) despia("appsflyer://log_event?event_name=video_completed&event_values=" + encodeURIComponent(JSON.stringify({ video_id: "v1", duration_s: 180 }))) despia("appsflyer://log_event?event_name=feature_used&event_values=" + encodeURIComponent(JSON.stringify({ feature: "dark_mode" }))) ``` To postback a custom event to Meta or TikTok, map it manually in **Configuration > Integrated Partners > \[Partner] > In-App Events**. Standard `af_` events are mapped automatically. *** ## Frequently Asked Questions `af_revenue` is counted as real revenue in AppsFlyer and reported in your dashboard and raw data. Use it for actual purchases. `af_price` is a monetary value that is not counted as revenue. Use it for events like `af_add_to_cart` or `af_start_trial` where you want to pass a price but not record revenue. For Meta's `StartTrial` mapping specifically, use `af_price`, not `af_revenue`. For standard `af_` events, AppsFlyer maps them automatically to Meta and TikTok. For custom events you need to add them manually in **Configuration > Integrated Partners > \[Partner] > In-App Events** if you want them postbacked to partners. Yes. Use `af_content_list` with a stringified array of IDs and `af_content` with a stringified array of objects, each containing `id` and `quantity`. Do not include `af_content_id` in the same event when using `af_content_list`. Fire an event with a negative `af_revenue` value and a custom event name like `cancel_purchase`: ```javascript theme={null} const refund = { af_revenue: -9.99, af_currency: "USD", af_content_id: "pro_plan" } despia("appsflyer://log_event?event_name=cancel_purchase&event_values=" + encodeURIComponent(JSON.stringify(refund))) ``` Yes. The native SDK queues events on the device and sends them when connectivity returns. No, but events logged after `set_user_id` are tied to your internal user ID in raw-data exports. Identify as early as you can, see [User Identity](/analytics/appsflyer/identity). *** ## Resources despia-native [support@despia.com](mailto:support@despia.com) # Identity Source: https://setup.despia.com/analytics/appsflyer/identity Tie attribution and events to your own user IDs, pass hashed email and phone for audience matching, and read the AppsFlyer device ID. Identity commands connect AppsFlyer's device-level attribution to your own user model. Everything here is forwarded to the native SDK. Email and phone are SHA256-hashed on the device before transmission, so raw PII never leaves the app. ## Installation ```bash npm theme={null} npm install despia-native ``` ```bash pnpm theme={null} pnpm add despia-native ``` ```bash yarn theme={null} yarn add despia-native ``` ```javascript theme={null} import despia from 'despia-native'; ``` ```html UMD theme={null} ``` ```html ESM theme={null} ``` *** ## set\_user\_id Call as soon as the user signs up or logs in. All subsequent events carry your internal user ID across AppsFlyer, Meta, and TikTok reporting, and raw-data exports become joinable with your own database. ```javascript theme={null} const userId = "user_123" despia("appsflyer://set_user_id?customer_user_id=" + encodeURIComponent(userId)) ``` Your internal, stable user identifier. Avoid emails or anything that changes. *** ## set\_email Passes the user's email for audience matching. The value is normalized and SHA256-hashed natively before it is sent. ```javascript theme={null} const email = "user@example.com" despia("appsflyer://set_email?email=" + encodeURIComponent(email)) ``` Lowercase and trim the email in your web layer before sending. Hashes only match across systems when the input is normalized identically everywhere. *** ## set\_phone Passes the user's phone number, SHA256-hashed natively and attached to the user's event data as `phone_hash`. ```javascript theme={null} const phone = "+15550001234" despia("appsflyer://set_phone?phone=" + encodeURIComponent(phone)) ``` Send phone numbers in E.164 format (`+`, no spaces or dashes) so hashes are consistent. *** ## get\_uid Reads the AppsFlyer device ID. It is already injected on every page load as `despia.appsFlyerUID`. Use the await pattern when you need a guaranteed-fresh value inline: ```javascript theme={null} const data = await despia("appsflyer://get_uid", ["appsFlyerUID"]) const uid = data.appsFlyerUID // e.g. store next to the user record for server-to-server events await fetch("/api/users/me/af-uid", { method: "POST", body: uid }) ``` *** ## Recommended order Forward the user's cookie-banner choice with [set\_consent](/analytics/appsflyer/consent) before identifying or logging anything. `set_user_id`, then `set_email` and `set_phone` if you have them. Everything from here on is tied to the user. See [Events](/analytics/appsflyer/events). *** ## Resources despia-native [support@despia.com](mailto:support@despia.com) # Introduction Source: https://setup.despia.com/analytics/appsflyer/introduction Native AppsFlyer attribution, deep linking, events, identity, consent, and ad revenue, fully managed by Despia and controlled from your web layer. Despia embeds the native AppsFlyer SDK in your iOS and Android builds and manages its entire lifecycle for you: initialization at launch, session tracking on every foreground, attribution resolution, deep link handling, and local caching. Your web layer never touches the SDK directly. It reads injected variables and sends commands through the `appsflyer://` scheme. Enable AppsFlyer for your app and add your AppsFlyer Dev Key (plus your Apple App ID on iOS) to your project configuration, then rebuild. No data flows until the SDK is enabled and the app is rebuilt. *** ## Installation ```bash npm theme={null} npm install despia-native ``` ```bash pnpm theme={null} pnpm add despia-native ``` ```bash yarn theme={null} yarn add despia-native ``` ```javascript theme={null} import despia from 'despia-native'; ``` ```html UMD theme={null} ``` ```html ESM theme={null} ``` *** ## How it works Despia activates the native AppsFlyer SDK at app launch and starts a new session every time the app returns to the foreground. Attribution data is resolved natively, cached on the device, and injected into your web layer on every page load, with no setup, no requests, and no waiting: ```javascript theme={null} despia.appsFlyerAttribution // full attribution object, null until first resolution despia.appsFlyerReferrer // e.g. "tiktok_ad", "facebook_organic", "organic" despia.appsFlyerUID // unique AppsFlyer device ID ``` Commands fired from your web layer using `despia("appsflyer://...")` are intercepted natively and forwarded to AppsFlyer in real time. AppsFlyer then postbacks to your connected partners (Meta, TikTok, Google) based on your dashboard configuration. Emails and phone numbers are hashed with SHA256 on the device before transmission, so raw PII never leaves the app. When a user arrives through a OneLink, an ad click or a creator's affiliate link, the native layer resolves it and delivers the click's metadata (the product, creator, invite code, or offer you attached to the link) to your web app through the `window.onAppsFlyerDeepLink` callback. This works even for a brand-new user who installs from the store first: AppsFlyer stores the click server-side and delivers it on that first launch (deferred deep linking). Your app reads the payload and decides what to do with it, redirect, fetch, or render the right content in place. Put anything a fresh install must receive into `deep_link_sub1` through `deep_link_sub10`, the fields AppsFlyer guarantees on deferred resolution. On iOS, the AppsFlyer SDK waits up to 60 seconds for the App Tracking Transparency prompt to be answered before finalizing install attribution. This is handled automatically. *** ## Feature map Read where every user came from, TikTok, Meta, Google, affiliate, or organic, through injected variables. Receive OneLink payloads (direct and deferred) through a callback and open the right content in your app. Log standard and custom in-app events back to AppsFlyer, Meta, and TikTok. Connect Meta and TikTok as integrated partners and measure which campaigns drive the most valuable users. Report impression-level ad revenue per network for LTV and ROAS reporting. Set your customer user ID, pass hashed email and phone, and read the AppsFlyer UID. Forward GDPR and DMA consent choices from your cookie banner to the native SDK. Verify installs, events, and user IDs end to end before going live. *** ## Quick start Add your AppsFlyer Dev Key (and Apple App ID on iOS) to your project configuration and rebuild. The SDK, session tracking, and attribution injection activate automatically. ```javascript theme={null} if (despia.appsFlyerReferrer === "tiktok_ad") { showTikTokWelcome() } ``` ```javascript theme={null} despia("appsflyer://set_user_id?customer_user_id=" + encodeURIComponent(userId)) ``` ```javascript theme={null} despia("appsflyer://log_event?event_name=af_login&event_values=" + encodeURIComponent("{}")) ``` Define the callback early, before your bundle loads, and open the right content from the payload. ```javascript theme={null} window.onAppsFlyerDeepLink = (payload) => { if (payload.deep_link_value) openContent(payload.deep_link_value, payload.deep_link_sub1) } ``` *** ## Resources despia-native [support@despia.com](mailto:support@despia.com) # Testing Source: https://setup.despia.com/analytics/appsflyer/testing Confirm your AppsFlyer setup is working before you launch. No coding tools required. Before you go live, confirm that when someone installs your app AppsFlyer sees it, and that in-app actions like a purchase or a signup get recorded. This page walks you through verifying installs, events, attribution, and user IDs from your phone and the AppsFlyer dashboard. Enable AppsFlyer in your Despia app settings and turn on debug mode before testing. Turn debug mode off again before you submit to the App Store or Google Play, otherwise every install is marked as a test install and your real attribution data is not recorded. ## Installation ```bash npm theme={null} npm install despia-native ``` ```bash pnpm theme={null} pnpm add despia-native ``` ```bash yarn theme={null} yarn add despia-native ``` ```javascript theme={null} import despia from 'despia-native'; ``` ```html UMD theme={null} ``` ```html ESM theme={null} ``` *** ## How it works When your app is running, Despia passes data to AppsFlyer natively. To check that it is working, you register your test phone with AppsFlyer, install the app on that phone, and watch a live feed in the AppsFlyer dashboard to confirm the data comes through. Gate any test code behind the canonical user agent check so it only runs inside the app. ```javascript theme={null} const isDespia = navigator.userAgent.toLowerCase().includes('despia') if (isDespia) { // send a test purchase to confirm AppsFlyer is receiving data const purchase = { af_revenue: 9.99, af_currency: "USD" } despia("appsflyer://log_event?event_name=af_purchase&event_values=" + encodeURIComponent(JSON.stringify(purchase))) } ``` *** ## Register your test device AppsFlyer ignores data from unknown devices during testing to keep your real data clean, so you register your phone first. The easiest way is the free **My Device ID** app by AppsFlyer: open it, tap register, log in with your AppsFlyer account, and your phone is added automatically. Download on the App Store Download on Google Play You can also add your phone manually in the AppsFlyer dashboard under **Settings > Test Devices > Add device**. *** ## Install your app You do not need a Mac, Xcode, or Android Studio. Install the app the normal way for each platform. After you deploy in Despia, the build is uploaded to **TestFlight** automatically. Open TestFlight on your iPhone and install the latest build. Before installing, delete any existing version of the app so AppsFlyer registers a fresh install. In Despia, go to **Build History > Quick Test** and download the APK directly to your Android phone. Delete any existing version first. If your phone asks to allow installation from unknown sources, tap allow. *** ## Watch installs in real time AppsFlyer has a live feed that shows installs and events as they happen on your test phone. In the AppsFlyer dashboard, go to **Settings > SDK Integration Tests**, click **Live Events**, pick your app, pick your test phone, and click **Continue**. The install should appear on the dashboard within a few seconds. To confirm the attribution arriving in your app is correct, add this and open the app. A popup shows exactly what AppsFlyer knows about where the install came from. ```javascript theme={null} const isDespia = navigator.userAgent.toLowerCase().includes('despia') if (isDespia) { alert("Where did this user come from: " + despia.appsFlyerReferrer) alert("AppsFlyer user ID: " + despia.appsFlyerUID) alert("Full attribution data: " + JSON.stringify(despia.appsFlyerAttribution, null, 2)) } ``` *** ## Check that events are recorded With the Live Event Viewer still open, trigger some actions and watch them appear. Each event shows up with its name and a status of **200 OK**, which means AppsFlyer received it. ```javascript theme={null} const isDespia = navigator.userAgent.toLowerCase().includes('despia') if (isDespia) { // a purchase despia("appsflyer://log_event?event_name=af_purchase&event_values=" + encodeURIComponent(JSON.stringify({ af_revenue: 9.99, af_currency: "USD" }))) // a login despia("appsflyer://log_event?event_name=af_login&event_values=" + encodeURIComponent("{}")) // finishing onboarding despia("appsflyer://log_event?event_name=complete_onboarding&event_values=" + encodeURIComponent("{}")) } ``` *** ## Check that your user ID is sent When a user logs in, you want AppsFlyer to know who they are so you can match events to real users. Add this after your login flow and confirm the event shows up in the Live Event Viewer with the customer user ID attached. ```javascript theme={null} const isDespia = navigator.userAgent.toLowerCase().includes('despia') if (isDespia) { const userId = "test_user_123" despia("appsflyer://set_user_id?customer_user_id=" + encodeURIComponent(userId)) // send an event right after so you can see the ID is attached despia("appsflyer://log_event?event_name=af_login&event_values=" + encodeURIComponent("{}")) } ``` *** ## Test deep links Deep links resolve natively and arrive through `window.onAppsFlyerDeepLink`, so verify them by watching the callback, not the page URL. Register the handler early and log what it receives, then open your app from a OneLink or install fresh after clicking one. ```javascript theme={null} window.onAppsFlyerDeepLink = function (payload) { alert("Deep link payload: " + JSON.stringify(payload, null, 2)) } ``` On a fresh install, the deferred deep link arrives on first launch, and on iOS after the App Tracking Transparency prompt is answered. See [Deep Linking](/analytics/appsflyer/deeplinking) for the full routing contract. *** ## Before you go live Turn off debug mode in your Despia AppsFlyer settings. Debug mode is only for testing. If you leave it on, all installs are marked as test installs and your real attribution data is not recorded. Rebuild the app in Despia for both iOS and Android and submit the new builds. The change does not take effect until users install the new version. Remove any `alert()` calls you added during testing so they do not appear for real users. Turning off debug mode requires a fresh native build. If you skip the rebuild, the app users install still runs in debug mode and marks every install as a test install, so no real attribution data is recorded. *** ## Resources despia-native [support@despia.com](mailto:support@despia.com) # Introduction Source: https://setup.despia.com/analytics/posthog/introduction GDPR-compliant product analytics, feature flags, and user identification bridged to your web layer via the Despia PostHog integration. GDPR-compliant product analytics, feature flags, and user identification fully bridged to your web layer. Track events, identify users, run experiments, and manage consent from your web app using `despia()`, with no native code and no native SDK to configure. *** ## Installation ```bash npm theme={null} npm install despia-native ``` ```bash pnpm theme={null} pnpm add despia-native ``` ```bash yarn theme={null} yarn add despia-native ``` ```javascript theme={null} import despia from 'despia-native'; ``` ```html UMD theme={null} ``` ```html ESM theme={null} ``` *** ## PostHog and Despia setup Create a PostHog project, then connect it to Despia. The steps go in order, each one feeds the next. Go to [app.posthog.com](https://app.posthog.com) and sign up, or sign in with an existing account. PostHog is free up to 1 million events per month, so account creation does not block early development. In PostHog, go to **Project Settings > Project API keys** and copy the project API key. It starts with `phc_`. This is the only credential Despia needs. Open the Despia Editor and go to **App > Settings > Integrations > PostHog**. Toggle the integration on, then paste in: * **API Key** - the `phc_...` key from the previous step * **Host** - leave blank to use `https://app.posthog.com`, or set a custom host for self-hosted PostHog instances Trigger a fresh build from the Despia Editor. The PostHog SDK has to be compiled into the app binary, so this cannot be applied over-the-air. After the build finishes, all `posthog://` scheme calls and injected globals will function in production. Skipping the rebuild leaves PostHog inactive even if the toggle reads enabled. Events will appear to fire with no errors but nothing will arrive in PostHog. If data stops flowing after editing credentials, rebuild before investigating further. *** ## How it works Despia initializes the native PostHog SDK at app start using your API key. Your web app does not load `posthog-js` - the native layer owns the SDK entirely. You interact through three things. **`posthog://` commands** (JS → native): fire events, identify users, manage consent using `despia("posthog://...")`. **`despia.postHog*` globals** (native → JS): four variables injected before your code runs and kept live by the native layer. Read them synchronously at any point - no scheme call, no callback, no await. ```javascript theme={null} despia.postHogDistinctId // string - current PostHog distinct_id despia.postHogSessionId // string - current session ID ("" until first event opens a session) despia.postHogOptedOut // boolean - true if the user has opted out of tracking despia.postHogFlags // object - all evaluated feature flag values, keyed by flag name ``` **`window.onPostHogEvent(evt)`** (native → JS, optional): lifecycle pushes when identity, flags, or opt-out status changes. Define it to react to these changes without polling the globals. All schemes, parameters, globals, callback payloads, and encoding rules. Configure feature flags, funnels, and session recordings. *** ## Platform gating The `posthog://` schemes and the `despia.postHog*` globals only exist inside the Despia runtime. Gate every call and every global read. ```javascript theme={null} const isDespia = navigator.userAgent.toLowerCase().includes('despia') if (isDespia) { despia("posthog://capture?event=" + encodeURIComponent("Checkout")) } if (isDespia && despia.postHogFlags["new_checkout"]) { // show new checkout UI } ``` *** ## Capture an event ```javascript theme={null} const isDespia = navigator.userAgent.toLowerCase().includes('despia') if (isDespia) { const props = { plan: "pro", revenue: 29.99, currency: "USD" } despia("posthog://capture?event=" + encodeURIComponent("Checkout") + "&properties=" + encodeURIComponent(JSON.stringify(props))) } ``` The native layer automatically appends `$current_url` and `$pathname` from the WebView's current route to every `capture` and `screen` call. Your own properties win on any key conflict. *** ## Identify a user Call `posthog://identify` as soon as a user logs in. All subsequent events link to their identity until `posthog://reset` is called on logout. After `identify` fires, `despia.postHogDistinctId` and `despia.postHogSessionId` update in place and `window.onPostHogEvent` fires with `{ type: "identity", distinctId, sessionId }`. ```javascript theme={null} const isDespia = navigator.userAgent.toLowerCase().includes('despia') if (isDespia) { const person = { email: "user@example.com", plan: "pro" } despia("posthog://identify?distinct_id=" + encodeURIComponent(user.id) + "&properties=" + encodeURIComponent(JSON.stringify(person))) } ``` *** ## Feature flags All evaluated flag values are available synchronously in `despia.postHogFlags`. No scheme call needed. ```javascript theme={null} const isDespia = navigator.userAgent.toLowerCase().includes('despia') if (isDespia) { const enabled = despia.postHogFlags["new_checkout"] // boolean flag const variant = despia.postHogFlags["checkout_variant"] // e.g. "control" or "test_v2" // A flag not yet loaded is absent from the object if ("new_checkout" in despia.postHogFlags) { // flag has a value } } ``` *** ## GDPR consent PostHog tracking requires explicit user consent under GDPR. Call `posthog://opt_out` when the user declines and `posthog://opt_in` when they accept. PostHog enforces the cutoff at the SDK level - events fired after `opt_out` are dropped before they reach PostHog servers. `despia.postHogOptedOut` updates in place when either scheme fires. ```javascript User declines theme={null} const isDespia = navigator.userAgent.toLowerCase().includes('despia') if (isDespia) { despia("posthog://opt_out") } ``` ```javascript User accepts theme={null} const isDespia = navigator.userAgent.toLowerCase().includes('despia') if (isDespia) { despia("posthog://opt_in") } ``` ```javascript theme={null} // Drive your consent UI from the live global const isDespia = navigator.userAgent.toLowerCase().includes('despia') if (isDespia && despia.postHogOptedOut) { // show opt-in prompt } ``` *** ## Resources despia-native Configure feature flags, funnels, and session recordings Feature flags, person properties, and group analytics [support@despia.com](mailto:support@despia.com) # Reference Source: https://setup.despia.com/analytics/posthog/reference Complete reference for Despia PostHog analytics, schemes, parameters, injected globals, and feature flags. ## Installation ```bash npm theme={null} npm install despia-native ``` ```bash pnpm theme={null} pnpm add despia-native ``` ```bash yarn theme={null} yarn add despia-native ``` ```javascript theme={null} import despia from 'despia-native'; ``` ```html UMD theme={null} ``` ```html ESM theme={null} ``` *** ## Capture an event Fire any named event with optional properties. This is the core call. PostHog merges your properties with the device context it captures automatically , OS, app version, screen size, and more. Your keys take precedence on any conflict. ```javascript theme={null} const props = { plan: "pro", revenue: 29.99, currency: "USD" } despia(`posthog://capture?event=Checkout&properties=${encodeURIComponent(JSON.stringify(props))}`) ``` | Parameter | Required | Description | | ------------ | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `event` | Yes | Event name. Case-sensitive. Pass as-is, no encoding needed. | | `properties` | No | Key-value pairs attached to the event. Wrap with `encodeURIComponent(JSON.stringify(...))`. Values can be strings, numbers, booleans, or flat arrays. Nested objects are not supported. | Use a consistent naming convention. PastTense verbs work well, e.g. `Signed Up`, `Checkout Completed`, `Article Read`. Avoid PII in event properties , use person properties via `identify` instead. *** ## Identify a user Associates the current anonymous device ID with a known user. Call this as soon as a user logs in. All subsequent events link to this identity until reset is called. ```javascript theme={null} const person = { email: "user@example.com", name: "Jane Smith", plan: "pro", created_at: "2025-01-15" } despia(`posthog://identify?distinct_id=user_123&properties=${encodeURIComponent(JSON.stringify(person))}`) ``` | Parameter | Required | Description | | ------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------- | | `distinct_id` | Yes | Your internal user ID. Pass as-is, no encoding needed. | | `properties` | No | Person properties stored on the PostHog person record, not event properties. Wrap with `encodeURIComponent(JSON.stringify(...))`. | Person properties on the PostHog person record have separate retention and access controls from event properties. Never send raw PII as event properties , put it on the person record where you can manage it. After identify fires, `despia.postHogDistinctId` and `despia.postHogSessionId` update in place and reflect the new identity. *** ## Merge a pre-signup ID Merges two distinct IDs into the same person. Use this at signup when a user has generated anonymous events before logging in and you want the pre-signup history to merge into their identified profile. ```javascript theme={null} despia('posthog://alias?alias=user_123') ``` | Parameter | Required | Description | | --------- | -------- | ----------------------------------------------------------------------------------- | | `alias` | Yes | The new ID to merge into the current `distinct_id`. Pass as-is, no encoding needed. | Call once at signup, immediately before or after `posthog://identify`. Do not call repeatedly. PostHog merges the anonymous session history permanently. *** ## Set group context Links all subsequent events in the session to a group , an organization, team, workspace, or account. Must be called again if the user switches group context. ```javascript theme={null} const groupProps = { name: "Acme Corp", plan: "enterprise", employee_count: 500 } despia(`posthog://group?type=company&key=acme_corp&properties=${encodeURIComponent(JSON.stringify(groupProps))}`) ``` | Parameter | Required | Description | | ------------ | -------- | ------------------------------------------------------------------------------------------------------------------------- | | `type` | Yes | Group type key, e.g. `company`, `team`, `workspace`. Must match a group type defined in your PostHog project. Pass as-is. | | `key` | Yes | Unique identifier for the specific group. Pass as-is. | | `properties` | No | Group-level properties stored on the group record. Wrap with `encodeURIComponent(JSON.stringify(...))`. | PostHog has a hard limit of 5 group types per project. Group types are created automatically on first use. Coordinate type naming with your PostHog project owner before shipping , renaming a type later requires a backfill. *** ## Track a screen view Sends a `$screen` event to PostHog with `$screen_name` set to the provided value. Use this on every route change inside your app. ```javascript theme={null} const props = { source: "tab_bar" } despia(`posthog://screen?name=Settings&properties=${encodeURIComponent(JSON.stringify(props))}`) ``` | Parameter | Required | Description | | ------------ | -------- | -------------------------------------------------------------------------------------------------------- | | `name` | Yes | Screen or view name, e.g. `Settings`, `Checkout`, `Profile`. Pass as-is. | | `properties` | No | Additional properties attached to the screen event. Wrap with `encodeURIComponent(JSON.stringify(...))`. | *** ## Set person properties Sets or updates properties on the PostHog person record without capturing an event. Use this when a user updates their profile or when you receive new attributes from your backend. ```javascript theme={null} const props = { plan: "enterprise", seats: 10 } despia(`posthog://set_person_properties?properties=${encodeURIComponent(JSON.stringify(props))}`) ``` | Parameter | Required | Description | | ------------ | -------- | --------------------------------------------------------------------------------------------------------------- | | `properties` | Yes | Properties to set. Merges with existing person properties. Wrap with `encodeURIComponent(JSON.stringify(...))`. | *** ## Register super properties Registers super properties , key-value pairs automatically appended to every subsequent `capture` call for the lifetime of the session. Useful for stable session-level context like `locale` or `app_version`. ```javascript theme={null} const superProps = { locale: navigator.language, app_version: "3.2.1" } despia(`posthog://register?properties=${encodeURIComponent(JSON.stringify(superProps))}`) ``` | Parameter | Required | Description | | ------------ | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | `properties` | Yes | Properties to register. Persisted across page loads until `posthog://unregister` or `posthog://reset`. Wrap with `encodeURIComponent(JSON.stringify(...))`. | Super properties merge with event-level properties. If the same key appears in both, the event-level value wins. *** ## Remove a super property Removes a single super property by key. The next captured event will no longer carry that key. ```javascript theme={null} despia('posthog://unregister?key=experiment_group') ``` | Parameter | Required | Description | | --------- | -------- | --------------------------------------------- | | `key` | Yes | The super property key to remove. Pass as-is. | *** ## Reset on logout Resets all user data and starts a fresh session. Clears the `distinct_id`, all person properties, all super properties, all group associations, and the session. Call this on logout only. ```javascript theme={null} despia('posthog://reset') ``` After reset fires, the native layer updates `despia.postHogDistinctId`, `despia.postHogSessionId`, and `despia.postHogFlags` in place. Reads of those globals immediately after the call reflect the refreshed values. *** ## Opt out of tracking Opts the user out of all PostHog tracking. No events are sent and no data is stored from this point forward. Persists across sessions until `posthog://opt_in` is called. ```javascript theme={null} despia('posthog://opt_out') ``` This is the GDPR consent cutoff. Under GDPR, analytics tracking requires explicit user consent before any data is collected. Call `opt_out` when the user declines your consent prompt. PostHog enforces the cutoff at the SDK level , events fired after `opt_out` are dropped before they reach PostHog servers. `despia.postHogOptedOut` updates to `true` when this fires. *** ## Opt in to tracking Opts the user in to PostHog tracking. Call this when the user explicitly accepts analytics in your consent prompt. ```javascript theme={null} despia('posthog://opt_in') ``` `despia.postHogOptedOut` updates to `false` when this fires. *** ## Reading current state The native layer keeps four globals live on the `despia` object. They update in place whenever their value changes, with no page reload required. Read them when you need the current value , do not snapshot them at boot, since the page-load push is asynchronous and may arrive after your startup code runs. ```javascript theme={null} despia.postHogDistinctId // current PostHog distinct_id despia.postHogSessionId // current session ID despia.postHogOptedOut // true if the user has opted out despia.postHogFlags // all evaluated feature flag values, keyed by flag name ``` | Global | Type | Description | | ------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `postHogDistinctId` | string | Updates on page load, after `identify`, and after `reset`. Defaults to `""` until the native layer populates it. | | `postHogSessionId` | string | Updates on page load, after `identify`, after `reset`, and once the first captured event opens a session. Defaults to `""`. | | `postHogOptedOut` | boolean | Updates on page load and after `opt_in` / `opt_out`. Defaults to `false`. | | `postHogFlags` | object | Updates on page load and whenever PostHog pushes new flag values. Defaults to `{}`. A flag not yet loaded is absent from the object , use `"flag_name" in despia.postHogFlags` to check. | Reading a feature flag is synchronous and always reflects the most recent push from PostHog. No scheme call is needed. ```javascript theme={null} const enabled = despia.postHogFlags["new_checkout"] // boolean flag const variant = despia.postHogFlags["checkout_variant"] // multivariate, e.g. "control" or "test_v2" ``` *** ## Standard auth flow Correct call order for a standard login and logout sequence. ```javascript theme={null} import despia from 'despia-native' const isDespia = navigator.userAgent.toLowerCase().includes('despia') // 1. On app load , register session-level super properties if (isDespia) { const superProps = { locale: navigator.language, app_version: window.APP_VERSION } despia(`posthog://register?properties=${encodeURIComponent(JSON.stringify(superProps))}`) } // 2. On login , identify the user if (isDespia) { const person = { email: user.email, plan: user.plan } despia(`posthog://identify?distinct_id=${user.id}&properties=${encodeURIComponent(JSON.stringify(person))}`) } // 3. For B2B apps , associate with org after identify if (isDespia) { const groupProps = { name: user.orgName, plan: user.orgPlan } despia(`posthog://group?type=company&key=${user.orgId}&properties=${encodeURIComponent(JSON.stringify(groupProps))}`) } // 4. Track actions if (isDespia) { const props = { plan: "pro", revenue: 29.99 } despia(`posthog://capture?event=Checkout&properties=${encodeURIComponent(JSON.stringify(props))}`) } // 5. On logout , reset everything if (isDespia) { despia('posthog://reset') } ``` *** ## Not bridged | Capability | Reason | | --------------------------------------- | ----------------------------------------------------------------------------------- | | `getFeatureFlag` / `isFeatureEnabled` | Use `despia.postHogFlags` , synchronous, always current, no scheme call needed | | `reloadFeatureFlags` / `onFeatureFlags` | Flag values are cached natively and pushed into `despia.postHogFlags` automatically | | Session recording controls | Configured project-side in PostHog, not a per-user runtime concern | | Surveys | Run via PostHog's own JS embed, not a native SDK surface | *** ## Resources despia-native Configure feature flags, funnels, and session recordings Feature flags, person properties, and group analytics [support@despia.com](mailto:support@despia.com) # Account Source: https://setup.despia.com/authentication/clerk/account User Profile sheet, sign-out, attribution sync, and multi-tenant switching. Post sign-in routes: open the prebuilt profile sheet, sign out, sync the user ID into analytics SDKs, switch tenants. *** ## User Profile Clerk's prebuilt account-management sheet. The user can edit their profile, manage emails, phones, external accounts, passkeys, sessions, MFA, and delete their account. | Param | Values | Default | | ------------- | -------------------------------------- | ------- | | `dismissable` | `true`, `false`, `1`, `0`, `yes`, `no` | `true` | ```javascript theme={null} despia('clerk://userprofile?dismissable=true') ``` Requires the user to be signed in. Emits `userprofile` with `status: "closed"` when the sheet dismisses, so you can refresh the UI for any profile edits the user just made. ```javascript theme={null} window.onClerkEvent = (r) => { if (r.ok && r.event === 'userprofile' && r.status === 'closed') { // User dismissed the profile sheet, re-read state to pick up any edits despia('clerk://state') } } ``` Errors emit with `event: "userprofile"`: `not_configured`, `unsupported_os`, `sdk_not_linked`. *** ## Sign Out Revokes the current session server-side, stops the JWT refresher, clears `window.clerkJWT`, clears attribution IDs, and writes the signed-out cookie state. ```javascript theme={null} despia('clerk://signout') ``` ```json theme={null} { "ok": true, "event": "signOut", "status": "complete" } ``` Pair with your SPA router: ```javascript theme={null} window.onClerkEvent = (r) => { if (r.ok && r.event === 'signOut') { navigate('/') } } ``` `navigate` is your SPA router's navigation function. Errors emit with `event: "signOut"`: `not_configured`, `signout_failed`, `unsupported_os`, `sdk_not_linked`. *** ## Multi-Tenant Call `clerk://configure` again with a different publishable key. The bridge wipes the secure session store, signs the user out, clears attribution IDs, clears SSR cookies, and re-emits `ready` with `status: "reconfigured"`. ```javascript theme={null} async function switchTenant(newKey) { return new Promise((resolve) => { const prev = window.onClerkEvent window.onClerkEvent = (r) => { prev?.(r) if (r.event === 'ready' && r.publishableKey === newKey) { resolve(r) } } despia('clerk://configure?key=' + newKey) }) } ``` A second reconfigure while one is in flight returns `reconfigure_in_progress`. Wait for the first `ready` before issuing another. *** ## Attribution Sync Auto-syncs the Clerk userId into OneSignal and AppsFlyer. On by default. Every successful sign-in or sign-up pushes the userId to both SDKs, sign-out or tenant switch clears them. | Param | Values | Default | | --------- | --------------- | ------- | | `enabled` | `true`, `false` | `true` | ```javascript theme={null} // Default, already on despia('clerk://attribution') // Disable and clear any auto-set IDs despia('clerk://attribution?enabled=false') ``` ```json theme={null} { "ok": true, "event": "attribution", "status": "enabled" } ``` OneSignal sync only fires when Push Notifications is enabled in the Despia Editor. AppsFlyer sync only fires when AppsFlyer is enabled. To key analytics on your own internal user ID instead of Clerk's, override after sign-in. Last write wins. ```javascript theme={null} // Override OneSignal user ID despia('setonesignalplayerid://?user_id=internal_user_123') // Override AppsFlyer customer user ID despia('appsflyer://set_user_id?customer_user_id=internal_user_123') ``` *** ## Resources despia-native [support@despia.com](mailto:support@despia.com) # Auth Methods Source: https://setup.despia.com/authentication/clerk/auth-methods Auth Sheet, email/username/phone password, OAuth, Apple, email and phone OTP, passkeys, MFA, Enterprise SSO, password reset, and magic-link tickets. Present Clerk's prebuilt sheet, or drive each flow from your own UI. Both produce the same session and emit through `window.onClerkEvent`. [Configure](/authentication/clerk/introduction#configure) first. Successful sign-in flows emit: ```json theme={null} { "ok": true, "event": "signIn", "status": "complete", "userId": "user_2abcDEF", "emailAddress": "jane@example.com" } ``` `status` is `complete` when the session is established, otherwise a raw Clerk status (`needs_first_factor`, `needs_second_factor`, `needs_new_password`, `needs_identifier`, `missing_requirements`, `abandoned`) meaning the flow needs another step. The same applies to `signUp`, `emailCode`, `phoneCode`, `secondFactor`, `resetPassword`, and `ticket` events, when `status` is `complete` the payload carries `userId` and (if a primary email exists) `emailAddress`, the JWT refresher starts, and `window.clerkJWT` populates shortly after. Multi-step flows (email code, phone code, password reset, MFA) hold one pending sign-in in memory at a time. Starting one cancels any other in flight, and calling a later step before the start step returns `missing_state`. *** ## Auth Sheet Clerk's prebuilt sign-in sheet, native UI covering email/password, OAuth, magic links, MFA, password reset, and passkeys. | Param | Values | Default | | ------------- | -------------------------------------- | ------------ | | `mode` | `signinorup`, `signin`, `signup` | `signinorup` | | `dismissable` | `true`, `false`, `1`, `0`, `yes`, `no` | `true` | ```javascript theme={null} despia('clerk://authview?mode=signinorup&dismissable=true') ``` On Android `mode` is accepted but currently inert, the sheet always opens the combined sign-in-or-up screen. iOS honours all three modes. See [Platform support](/authentication/clerk/reference#platform-support). Emits `authview` when the sheet closes: ```json theme={null} { "ok": true, "event": "authview", "status": "complete", "userId": "user_2abcDEF", "emailAddress": "jane@example.com" } ``` `status: "complete"` fires when the sheet closes with a *different* user signed in than before, the payload carries `userId` and `emailAddress` (the latter only if the account has a primary email, a phone or username-only account omits it). This also covers switching accounts mid-sheet, an already-signed-in user who signs into a different account inside the sheet gets `complete` with the new `userId`. `status: "dismissed"` fires when the sheet closed without a user-identity change (swipe-down, cancel, or an already-signed-in user opening and closing the sheet without switching accounts). ```javascript theme={null} window.onClerkEvent = (r) => { if (r.ok && r.event === 'authview' && r.status === 'complete') { // User is signed in renderApp() } } ``` On `complete` the JWT refresher starts and populates `window.clerkJWT` **a tick later**, not in the same callback. If you need the token in the same handler, force a refresh with `clerk://token`: ```javascript theme={null} if (r.event === 'authview' && r.status === 'complete') { despia('clerk://token') // Next event will be { event: 'token', jwt: '...' }, and window.clerkJWT is now set } ``` For most apps reading `window.clerkJWT` on the next API call is enough, the refresher keeps it fresh thereafter. Errors: `not_configured`, `unsupported_os`, `sdk_not_linked`. *** ## Password Manual password sign-in or sign-up. Sign-in accepts email, username, or phone as the identifier. Sign-up requires a password plus at least one of email, username, or phone. | Param | Required | Notes | | ----------------------- | -------- | ------------------------------------------------------------------------------------- | | `method=password` | yes | | | `type` | no | `signin` (default) or `signup` | | `identifier` | sign-in | Email, username, or phone. Aliases on sign-in: `emailAddress`, `email`, `username` | | `emailAddress` | sign-up | At least one of `emailAddress` (alias `email`), `username`, or `phoneNumber` required | | `username` | sign-up | At least one of `emailAddress`, `username`, or `phoneNumber` required | | `phoneNumber` | sign-up | At least one of `emailAddress`, `username`, or `phoneNumber` required | | `password` | yes | | | `firstName`, `lastName` | no | Sign-up only, optional | ```javascript theme={null} // Sign in with email despia('clerk://manual?method=password&type=signin&identifier=jane@example.com&password=hunter2') // Sign in with username despia('clerk://manual?method=password&type=signin&identifier=jane&password=hunter2') // Sign in with phone despia('clerk://manual?method=password&type=signin&identifier=+15551234567&password=hunter2') // Sign up with email despia('clerk://manual?method=password&type=signup&emailAddress=new@example.com&password=hunter2&firstName=Sam') // Sign up with username only despia('clerk://manual?method=password&type=signup&username=jane&password=hunter2') ``` Emits `signIn` or `signUp`. Errors: `missing_params`, `auth_failed`. *** ## OAuth System-browser OAuth via `ASWebAuthenticationSession` (iOS) or Chrome Custom Tabs (Android). | Param | Required | Notes | | -------------- | -------- | ---------------------------------------------------- | | `method=oauth` | yes | | | `provider` | yes | See providers below | | `type` | no | `signin` (default) or `signup` | | `ephemeral` | no | `true` skips shared browser cookies. Default `false` | ```javascript theme={null} despia('clerk://manual?method=oauth&provider=google') ``` Emits `signIn` or `signUp` depending on whether the external account is new. **Providers:** `google`, `github`, `microsoft`, `gitlab`, `discord`, `twitter` (alias `x`), `twitch`, `linkedin`, `linkedin_oidc`, `facebook`, `tiktok`, `dropbox`, `atlassian`, `bitbucket`, `notion`, `line`, `instagram`, `coinbase`, `spotify`, `xero`, `box`, `slack`, `linear`, `hubspot`, `huggingface`, `vercel`. Anything else routes to Clerk's custom provider handler. Errors: `missing_params`, `use_native_apple`, `oauth_failed`. On Android, `ephemeral=true` is accepted but currently inert (Chrome Custom Tabs has no private-session option). Unrecognized provider values run as Clerk's generic custom strategy rather than carrying the specific custom provider key. See [Platform support](/authentication/clerk/reference#platform-support). `provider=apple` is rejected with `use_native_apple`. App Review requires the native Apple sheet, use Apple Sign In below. *** ## Apple Sign In Native `ASAuthorizationController` sheet on iOS, Apple OAuth via Chrome Custom Tabs on Android. JS contract is identical. | Param | Required | Notes | | -------------- | -------- | ------------------------------ | | `method=apple` | yes | | | `type` | no | `signin` (default) or `signup` | ```javascript theme={null} despia('clerk://manual?method=apple') ``` Emits `signIn` or `signUp`. Errors: `apple_failed`. *** ## Email Code Two-step email OTP for sign-in or sign-up. Verify must follow start in the same app session. | Param | Required | Notes | | ----------------------------------- | -------- | ----------------------------------------------------------------- | | `method=email_code` | yes | | | `action` | no | `start` (default) or `verify` | | `type` | no | `signin` (default) or `signup`. Pass on **both** start and verify | | `emailAddress` | start | Or alias `email` | | `firstName`, `lastName`, `username` | start | Sign-up only, optional | | `code` | verify | The OTP the user received | ```javascript theme={null} // Sign in despia('clerk://manual?method=email_code&action=start&emailAddress=jane@example.com') despia('clerk://manual?method=email_code&action=verify&code=123456') // Sign up despia('clerk://manual?method=email_code&action=start&type=signup&emailAddress=jane@example.com&firstName=Jane') despia('clerk://manual?method=email_code&action=verify&type=signup&code=123456') ``` Emits `emailCode` with `status: "code_sent"` after start, then `complete` (or a `needs_*` status) after verify. Errors: `missing_params`, `missing_state` (verify before start), `unknown_action`, `email_code_failed`. *** ## Phone Code Two-step SMS OTP. Verify must follow start in the same app session. Shares pending-flow state with Email Code, only one OTP flow runs at a time. | Param | Required | Notes | | ------------------- | -------- | -------------------------------------------------------------------------------------------------- | | `method=phone_code` | yes | Alias `sms` | | `action` | no | `start` (default) or `verify` | | `phoneNumber` | start | E.164 like `+15551234567`. Alias `phone`. Human formatting like `+1 (555) 123-4567` is normalized. | | `code` | verify | The OTP from the SMS | ```javascript theme={null} // 1. Send the SMS despia('clerk://manual?method=phone_code&action=start&phoneNumber=+15551234567') // 2. Verify (after user enters code) despia('clerk://manual?method=phone_code&action=verify&code=123456') ``` Emits `phoneCode` with `status: "code_sent"` after start, then `complete` (or a `needs_*` status) after verify. Errors: `missing_params`, `missing_state` (verify before start), `unknown_action`, `phone_code_failed`. *** ## Passkeys Native passkey UI. `signin` uses an existing passkey, `register` adds one to the current signed-in user. | Param | Required | Notes | | ---------------- | -------- | -------------------------------- | | `method=passkey` | yes | | | `action` | no | `signin` (default) or `register` | ```javascript theme={null} // Sign in with an existing passkey despia('clerk://manual?method=passkey&action=signin') // Register a new passkey on the signed-in account despia('clerk://manual?method=passkey&action=register') ``` `signin` emits a normal `signIn` event. `register` emits: ```json theme={null} { "ok": true, "event": "passkey", "status": "registered", "passkeyId": "psk_2abc", "name": "iPhone 15" } ``` Saved to iCloud Keychain (iOS) or Google Password Manager (Android). Errors: `not_signed_in`, `unknown_action`, `passkey_failed`. *** ## MFA Continues a sign-in whose first-factor result came back with `status: "needs_second_factor"`. Consumes the pending sign-in. | Param | Required | Notes | | ---------------------- | -------- | ------------------------------------------------------------ | | `method=second_factor` | yes | Aliases `mfa`, `2fa` | | `action` | no | `verify` (default) or `prepare` (sends an SMS or email code) | | `strategy` | no | `totp` (default), `phone_code`, `email_code`, `backup_code` | | `code` | verify | The MFA code or backup code | ```javascript theme={null} // TOTP from an authenticator app, single call despia('clerk://manual?method=second_factor&code=123456') // Backup code, single call despia('clerk://manual?method=second_factor&strategy=backup_code&code=ab-cd-ef') // SMS second factor, two calls despia('clerk://manual?method=second_factor&strategy=phone_code&action=prepare') despia('clerk://manual?method=second_factor&strategy=phone_code&code=123456') // Email second factor, two calls despia('clerk://manual?method=second_factor&strategy=email_code&action=prepare') despia('clerk://manual?method=second_factor&strategy=email_code&code=123456') ``` TOTP and backup codes verify in a single call with no prepare step. Phone and email factors need `action=prepare` first to send the code. Emits `secondFactor` with `status: "code_sent"` after prepare, then `complete` (or another `needs_*` status) after verify. Errors: `missing_state` (no pending sign-in to continue), `missing_params`, `prepare_not_needed` (called prepare on a strategy that doesn't need one), `second_factor_failed`. *** ## Enterprise SSO SAML / OIDC enterprise sign-in. Routes the user to their organization's identity provider based on their email domain. | Param | Required | Notes | | ----------------------- | -------- | ---------------------------------------------------- | | `method=enterprise_sso` | yes | Aliases `sso`, `saml` | | `emailAddress` | yes | Or alias `email` | | `ephemeral` | no | `true` skips shared browser cookies. Default `false` | ```javascript theme={null} despia('clerk://manual?method=enterprise_sso&emailAddress=jane@acme.com') ``` OAuth-style transfer flow, emits `signIn` or `signUp` depending on whether the user is new to the organization. Errors emit with `event: "enterpriseSSO"`: `missing_params`, `enterprise_sso_failed`. On Android, `ephemeral=true` is accepted but currently inert. See [Platform support](/authentication/clerk/reference#platform-support). *** ## Reset Password Three-step password reset. The pending sign-in lives in memory across steps, all three must run in the same app session. | Param | Required | Notes | | ----------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | `method=reset_password` | yes | Aliases `reset`, `forgot_password` | | `action` | no | `start` (default), `verify`, or `reset` | | `identifier` | start | Email or phone. Aliases `emailAddress`, `email`, `phoneNumber`, `phone`. Phone triggers an SMS reset code, email triggers an email reset code. | | `code` | verify | The reset code from email or SMS | | `newPassword` | reset | The new password. Alias `password` | ```javascript theme={null} // 1. Send a reset code despia('clerk://manual?method=reset_password&action=start&identifier=jane@example.com') // 2. Verify the code despia('clerk://manual?method=reset_password&action=verify&code=123456') // 3. Set the new password despia('clerk://manual?method=reset_password&action=reset&newPassword=NewSecret123') ``` Emits `resetPassword` with `status: "code_sent"` → `needs_new_password` → `complete`. Errors: `missing_params`, `missing_state`, `unknown_action`, `reset_password_failed`. *** ## Magic Link Ticket Native equivalent of an email magic link. Clerk's mobile SDKs don't include an email-link sign-in strategy, so the bridge takes a one-time ticket your backend mints through Clerk's API. Your backend emails or pushes a deep link carrying the ticket, the app forwards the ticket to the bridge to complete sign-in. | Param | Required | Notes | | --------------- | -------- | -------------------------------------------------------- | | `method=ticket` | yes | Alias `magic_link` | | `ticket` | yes | The one-time Clerk sign-in ticket minted by your backend | ```javascript theme={null} despia('clerk://manual?method=ticket&ticket=' + ticketFromDeepLink) ``` Emits a normal `signIn` event with the result status. Errors emit with `event: "ticket"`: `missing_params`, `ticket_failed`. *** ## Resources despia-native [support@despia.com](mailto:support@despia.com) # Backend Source: https://setup.despia.com/authentication/clerk/backend Zero-config server-side authentication for Next.js, TanStack, and any framework via cookies and an optional header. A native session bridge into your page's server middleware. The bridge writes the session into the WebView's cookie store (and optionally injects a request header) so Next.js, TanStack, or any framework's middleware sees an authenticated user on the very first request, no Clerk web SDK on the page required. On by default with sensible scoping, most apps never need to tune it. *** ## Parameters | Param | Default | Notes | | --------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ | | `enabled` | `true` | `false` purges every SSR cookie | | `header` | none | Header name to inject on main-frame navigations. `Authorization` gets a `Bearer` prefix, any other name gets the raw JWT | | `domains` | app host + `localhost` + `127.0.0.1` | Comma-separated override of the cookie/host scope | ```javascript theme={null} // Default, already on despia('clerk://ssr') // Inject Authorization on main-frame nav despia('clerk://ssr?header=Authorization') // Custom domain scope (separate API host) despia('clerk://ssr?domains=app.example.com,api.example.com') // Opt out and purge despia('clerk://ssr?enabled=false') ``` Emits: ```json theme={null} { "ok": true, "event": "ssr", "status": "enabled", "domains": ["app.example.com", "localhost", "127.0.0.1"], "header": "X-Clerk-JWT" } ``` *** ## Cookies Three cookies, scoped per configured domain, refreshed every 50 seconds. | Cookie | Value | Used by | | -------------- | -------------------------------------- | --------------------------------------------------------- | | `__session` | Session JWT | Clerk's `clerkMiddleware()` | | `__client_uat` | Unix-seconds signed in, `0` signed out | Clerk's middleware signed-in/out signal | | `clerk_token` | Session JWT | Manual `@clerk/backend` verification or client-side reads | Cookies are not `HttpOnly` (so client code on `localhost` can read `clerk_token`), `Secure` on real hosts, and not `Secure` on `localhost` / `127.0.0.1` so they work over `http://localhost:port`. *** ## Header transport With `header=`, the bridge intercepts main-frame GETs to configured domains and attaches the header. ```javascript theme={null} despia('clerk://ssr?header=Authorization') // Every page load now arrives with: Authorization: Bearer ``` `Authorization` gets a `Bearer` prefix. Any other header (`X-Clerk-JWT`, `X-Auth`) gets the raw JWT. *** ## Next.js `clerkMiddleware()` reads `__session` and `__client_uat` from request cookies, so the standard middleware just works. ```typescript theme={null} // middleware.ts import { clerkMiddleware } from '@clerk/nextjs/server' export default clerkMiddleware() ``` ```tsx theme={null} // app/dashboard/page.tsx import { auth } from '@clerk/nextjs/server' export default async function Page() { const { userId } = await auth() return } ``` No ``, no `useAuth()` hook, no web SDK. The native bridge writes the cookies and the middleware does the rest. *** ## Manual verification For any other backend, read the JWT and verify it with `@clerk/backend`. ```typescript theme={null} import { verifyToken } from '@clerk/backend' const jwt = req.cookies['clerk_token'] // or req.headers['x-clerk-jwt'] const { sub: userId } = await verifyToken(jwt, { secretKey: process.env.CLERK_SECRET_KEY, }) ``` Trust only the verified `sub`. Never accept a client-reported `userId`. *** ## Handshake invariant Clerk's middleware treats "a live `__client_uat` next to a missing/expired `__session`" as a redirect to Clerk's FAPI for a handshake, which a native WebView with no Clerk web SDK cannot satisfy. The bridge guarantees the cookie pair is only ever in one of two states middleware accepts without a handshake: * **Signed-in:** fresh JWT in `__session` plus `__client_uat > 0` * **Signed-out:** no `__session` plus `__client_uat = 0` Enforced structurally with a TTL ladder: the 50-second refresher runs under the 55-second cookie TTL, which runs under the 60-second JWT TTL. An un-refreshed cookie self-expires before its JWT does, so the browser can never present the handshake-triggering combination. Degradation is always toward signed-out, never toward broken handshake. A navigation gate adds a second layer. On a main-frame GET to a configured domain with a live session, if the cookie is stale (>45s) or the opt-in header is missing, the navigation is cancelled, a fresh JWT is minted, the cookie write is awaited, the header is attached, and the request is re-issued. Self-terminating, cannot loop. *** ## Cold launch The first page load of a cold launch is anonymous, Clerk is not configured until your page's JS has run and called [`clerk://configure`](/authentication/clerk/introduction#configure). That first load lands in the safe signed-out state, never a handshake. Every navigation after configure is authenticated. If you need authenticated SSR on the very first byte, configure earlier in your boot or render a brief loading shell that calls configure and then navigates to the real route once `window.clerkJWT` is populated. *** ## Offline A failed refresh leaves the existing cookies in place. Once they hit their 55-second TTL the browser presents the signed-out state to middleware rather than the handshake combination. The user effectively becomes signed-out for new server-rendered requests until the device comes back online. *** ## Resources despia-native [support@despia.com](mailto:support@despia.com) # Introduction Source: https://setup.despia.com/authentication/clerk/introduction Drop-in authentication for your Despia app with email, OAuth, passkeys, and MFA handled by Clerk's native iOS SDK. Native Clerk authentication for iOS and Android. The host app embeds Clerk's native SDK, your web app drives it from JavaScript through `despia()` calls and two global hooks. Optional, for apps that need Keychain or Keystore backed sessions, native Sign in with Apple, offline cold-launch, or zero-config server-side auth. Web auth providers like Supabase, Firebase, NextAuth, Auth0, or Clerk's own web SDK work in Despia out of the box without this. Requires iOS 17 or Android API 24, and a publishable key from your [Clerk Dashboard](https://dashboard.clerk.com). *** ## Installation ```bash npm theme={null} npm install despia-native ``` ```bash pnpm theme={null} pnpm add despia-native ``` ```bash yarn theme={null} yarn add despia-native ``` ```javascript theme={null} import despia from 'despia-native'; ``` ```html UMD theme={null} ``` ```html ESM theme={null} ``` *** ## How it works The bridge exposes two globals on `window`. You define `window.onClerkEvent` to receive every event, the bridge writes `window.clerkJWT` with the current session token and auto-refreshes it every 50 seconds. | Global | Type | Lifecycle | | | --------------------- | ------------------- | --------------------------------- | ---------------------------------------------------------------- | | `window.onClerkEvent` | `(payload) => void` | You define. Receives every event. | | | `window.clerkJWT` | \`string | null\` | Bridge writes. Auto-refreshed every 50s. `null` when signed out. | Wire the listener first, then configure the bridge. Events are not buffered, a handler attached after a command was issued will miss the result. ```javascript theme={null} const isDespia = navigator.userAgent.toLowerCase().includes('despia') if (isDespia) { window.onClerkEvent = (r) => { if (!r.ok) { console.error(r.event, r.error.code, r.error.message) return } if (r.event === 'ready') { despia('clerk://state') } if (r.event === 'state') { if (r.isSignedIn) { renderApp(r.user) } else { despia('clerk://authview') } } } despia('clerk://configure?key=pk_test_abc123') } ``` Every event has the same envelope: ```typescript theme={null} { ok: boolean, // false means error present event: string, // 'ready', 'state', 'signIn', etc. status?: string, // success detail error?: { code: string, message: string }, // when ok === false // plus route-specific fields } ``` *** ## Configure Initialize the bridge with your Clerk publishable key on every page load. Same-key calls re-emit `ready` as a no-op. Different keys switch tenant and clear the secure session store. ```javascript theme={null} despia('clerk://configure?key=pk_test_abc123') ``` Emits: ```json theme={null} { "ok": true, "event": "ready", "status": "configured", "publishableKey": "pk_test_abc123" } ``` `status` is `configured`, `already_configured`, or `reconfigured`. Errors emit with `event: "configure"`: `invalid_key`, `reconfigure_in_progress`, `reconfigure_failed`, `unsupported_os`, `sdk_not_linked`. *** ## Next Auth Sheet, password, OAuth, Apple, email code, phone code, passkeys, MFA, SSO. Read who's signed in and the auto-refreshed JWT. Next.js and TanStack authenticated on first paint. User Profile, sign-out, attribution, multi-tenant. Events, status values, error codes, quirks. *** ## Resources despia-native [support@despia.com](mailto:support@despia.com) # Reference Source: https://setup.despia.com/authentication/clerk/reference Payload envelope, event names, status values, error codes, quirks, and platform support. Flat reference for everything the bridge emits. For per-method documentation, see [Auth Methods](/authentication/clerk/auth-methods), [Sessions](/authentication/clerk/sessions), [Backend](/authentication/clerk/backend), and [Account](/authentication/clerk/account). Shape of every `onClerkEvent` callback Every event name the bridge can emit What `status` strings mean Every `error.code` and what fires it Bridge behavior worth knowing iOS vs Android, +3 Android divergences *** ## Payload envelope Every `onClerkEvent` callback receives an object with this shape: ```typescript theme={null} { ok: boolean, // false means error present event: string, // see event names below status?: string, // success detail error?: { code: string, message: string }, // when ok === false // plus route-specific fields documented per page } ``` Always branch on `ok` first. ```javascript theme={null} window.onClerkEvent = (r) => { if (!r.ok) { console.error(r.event, r.error.code, r.error.message) return } // success path } ``` *** ## Events Every command emits at least one event. Some routes emit only error envelopes, their success transfers into a different event (for example, OAuth success becomes a `signIn` or `signUp` event). | Event | Emitted by | Reference | | --------------------------------------- | ------------------------------ | ------------------------------------------------------------------- | | `ready` | Configure | [Configure](/authentication/clerk/introduction#configure) | | `state` | State snapshot | [Session State](/authentication/clerk/sessions#session-state) | | `token` | JWT refresh | [Force a refresh](/authentication/clerk/sessions#force-a-refresh) | | `signIn` | Any sign-in reaching a session | [Auth Methods](/authentication/clerk/auth-methods) | | `signUp` | Any sign-up reaching a session | [Auth Methods](/authentication/clerk/auth-methods) | | `emailCode` | Email OTP flow | [Email Code](/authentication/clerk/auth-methods#email-code) | | `phoneCode` | SMS OTP flow | [Phone Code](/authentication/clerk/auth-methods#phone-code) | | `secondFactor` | MFA continuation | [MFA](/authentication/clerk/auth-methods#mfa) | | `resetPassword` | Password reset flow | [Reset Password](/authentication/clerk/auth-methods#reset-password) | | `passkey` | Passkey registration | [Passkeys](/authentication/clerk/auth-methods#passkeys) | | `authview` | Auth Sheet close | [Auth Sheet](/authentication/clerk/auth-methods#auth-sheet) | | `userprofile` | User Profile sheet close | [User Profile](/authentication/clerk/account#user-profile) | | `signOut` | Sign Out | [Sign Out](/authentication/clerk/account#sign-out) | | `attribution` | Attribution toggle | [Attribution Sync](/authentication/clerk/account#attribution-sync) | | `ssr` | SSR toggle | [Backend](/authentication/clerk/backend) | | `enterpriseSSO` | Enterprise SSO errors | Success transfers to `signIn`/`signUp` | | `ticket` | Magic Link Ticket errors | Success emits `signIn` | | `configure`, `manual`, `oauth`, `apple` | Error envelopes only | Success of each transfers to a different event | | `unknown` | Unrecognized `clerk://` host | Returns `unknown_command` | *** ## Status values `status` on `signIn`, `signUp`, `emailCode`, `phoneCode`, `secondFactor`, and `resetPassword` is the raw Clerk SDK status, passed through verbatim. Match `complete` explicitly and treat anything else as "needs another step." | Status | Meaning | | ---------------------- | ---------------------------------------------------------------------------------------------------- | | `complete` | Session established. Payload carries `userId` and (if present) `emailAddress`. JWT refresher starts. | | `needs_identifier` | Sign-in missing the identifier | | `needs_first_factor` | Password or other first factor required | | `needs_second_factor` | MFA required, continue with [`method=second_factor`](/authentication/clerk/auth-methods#mfa) | | `needs_new_password` | Password reset required | | `needs_client_trust` | Trusted-device challenge required | | `missing_requirements` | Sign-up missing email/phone verification | | `abandoned` | Sign-up timed out | | Status | Meaning | | ---------------------- | ------------------------------------------------------ | | `configured` | First-time configure success | | `already_configured` | Same publishable key re-passed | | `reconfigured` | Different key, tenant switched | | `code_sent` | OTP code dispatched (email, SMS, or MFA second factor) | | `registered` | Passkey added to the signed-in account | | `dismissed` | Auth Sheet closed without a new sign-in | | `closed` | User Profile sheet closed | | `enabled` / `disabled` | `attribution` or `ssr` toggle | *** ## Error codes Every error envelope follows `{ ok: false, event, error: { code, message } }`. `error.message` is human-readable (Clerk's own text for SDK errors, the bridge's text for guard errors). Error codes are grouped by the route that emits them. | Code | Triggered by | Meaning | | ------------------------- | ----------------------- | -------------------------------------------------- | | `invalid_key` | `configure` | Key missing or does not start with `pk_` | | `reconfigure_in_progress` | `configure` | Previous tenant switch still running | | `reconfigure_failed` | `configure` | SDK reconfigure threw | | `not_configured` | Any non-configure route | `clerk://configure` not called yet | | `signout_failed` | `signout` | Network error during server-side revoke | | `token_fetch_failed` | `token` | Refresh failed, `window.clerkJWT` keeps last value | | Code | Triggered by | Meaning | | ----------------- | -------------------------------------------------- | ----------------------------------------------------------------------------------------- | | `unsupported_os` | All routes | Below the Clerk SDK runtime minimum (iOS 17+, clerk-android's minimum API level) | | `sdk_not_linked` | All routes | Clerk SDK or prebuilt UI not in the build | | `unknown_command` | Unknown host | For example, `clerk://foo` | | `unknown_method` | `manual` | `method=` not recognized | | `unknown_action` | OTP, reset password, passkey | `action=` not recognized for the route | | `missing_params` | Manual flows | Required field absent | | `missing_state` | OTP verify, reset password steps, MFA continuation | A later step called before its prerequisite (shared state, one multi-step flow at a time) | | Code | Triggered by | Meaning | | ------------------ | ---------------------- | ------------------------------------------------------------------------------ | | `auth_failed` | `password` | Wrong credentials, account locked, etc. Clerk's message in `error.message` | | `oauth_failed` | `oauth` | User cancelled, network error, or provider denied | | `use_native_apple` | `oauth&provider=apple` | Use [`method=apple`](/authentication/clerk/auth-methods#apple-sign-in) instead | | `apple_failed` | `apple` | User cancelled Sign in with Apple | | Code | Triggered by | Meaning | | ----------------------- | ------------------------------ | ------------------------------------------------------------- | | `email_code_failed` | `email_code` | Code wrong or expired | | `phone_code_failed` | `phone_code` | Code wrong, expired, or SMS send failed | | `second_factor_failed` | `second_factor` | Wrong code, expired, or strategy error | | `prepare_not_needed` | `second_factor&action=prepare` | Strategy does not require a prepare step (TOTP, backup codes) | | `reset_password_failed` | `reset_password` | Wrong code, expired, password policy rejected, etc. | | Code | Triggered by | Meaning | | ----------------------- | ------------------------- | ------------------------------------------------------------------- | | `enterprise_sso_failed` | `enterprise_sso` | User cancelled, no SSO connection for the domain, or provider error | | `ticket_failed` | `ticket` | Ticket invalid, expired, or already used | | `passkey_failed` | `passkey` | User cancelled or no credential | | `not_signed_in` | `passkey&action=register` | Cannot register a passkey without a signed-in user | *** ## OAuth providers Recognized values for [`method=oauth`](/authentication/clerk/auth-methods#oauth). Anything else routes to Clerk's custom provider handler. `google`, `github`, `microsoft`, `gitlab`, `discord`, `twitter` (alias `x`), `twitch`, `linkedin`, `linkedin_oidc`, `facebook`, `tiktok`, `dropbox`, `atlassian`, `bitbucket`, `notion`, `line`, `instagram`, `coinbase`, `spotify`, `xero`, `box`, `slack`, `linear`, `hubspot`, `huggingface`, `vercel`. `provider=apple` is rejected with `use_native_apple`. Use [`method=apple`](/authentication/clerk/auth-methods#apple-sign-in) for the native sheet on iOS or Apple OAuth via Chrome Custom Tabs on Android. *** ## Quirks `despia()` calls for `clerk://` routes are fire-and-forget. Results arrive through `window.onClerkEvent`. Events are not buffered. A handler attached after a command was issued misses the result. Wire `window.onClerkEvent` before any `clerk://` call. `fetch()` and `XMLHttpRequest` cannot trigger `clerk://` URLs. Use `despia()` to invoke the bridge. `window.clerkJWT` populates a tick after sign-in completion, not synchronously. After `configure` or any flow reaching `status: "complete"`, the JWT refresher starts and `window.clerkJWT` is written on the next event loop tick. Read `window.clerkJWT` on your next API call, or force a synchronous refresh with `clerk://token` if you need the token immediately in the same handler. Every inbound URL and every outbound event is printed to the platform console with a `[Clerk]` prefix (Xcode console on iOS, Logcat on Android). The JWT value itself is never logged. Grep for `[Clerk]` while debugging to trace exactly what the bridge received and what it emitted. Toggle off with `ClerkBridge.LOG = false` on Android or `loggingEnabled = false` on iOS. If `window.onClerkEvent` is not defined as a function when the bridge tries to emit, the bridge logs a console warning identifying which event was dropped. The most common integration mistake is not defining the handler before the first `clerk://` call. The bridge splits the query string at known-key boundaries, so an unencoded `&`, `#`, or `=` inside a password survives intact. **Recognized keys:** `method`, `type`, `identifier`, `password`, `newPassword`, `email`, `emailAddress`, `firstName`, `lastName`, `username`, `phoneNumber`, `phone`, `code`, `action`, `provider`, `ephemeral`, `mode`, `dismissable`, `key`, `ticket`, `enabled`, `header`, `domains`. Values are percent-decoded once. Unrecognized parameter names are folded into the previous value and silently ignored. **Boolean parameters** accept `true`, `false`, `1`, `0`, `yes`, `no` (case-insensitive). **Mode values** are lowercase: `signin`, `signup`, `signinorup` (aliases `signin_or_up`, `default`). **Status values** pass through verbatim from the Clerk SDK. Match `complete` explicitly and treat anything else as "needs more steps." **Phone numbers** are normalized to E.164 natively. `+1 (555) 123-4567`, `%2B15551234567`, and `+15551234567` all reach Clerk as `+15551234567`. On the server, trust only the verified `sub` claim from the JWT. Never accept a client-reported `userId` as proof of identity. The JWT is verifiable against Clerk's JWKS, but client-supplied IDs are not. See [Backend](/authentication/clerk/backend) for verification patterns. *** ## Platform support Same `clerk://` URLs, event names, status strings, error codes, and payload shapes work identically on iOS and Android. Implementation differences happen under the hood, the JS contract is the same. ### Under-the-hood differences | Concern | iOS | Android | | ---------------------- | ---------------------------------- | ----------------------------------------------------- | | Secure session storage | Keychain | Android Keystore | | Apple Sign In | Native `ASAuthorizationController` | Apple OAuth via Chrome Custom Tabs | | OAuth provider flow | `ASWebAuthenticationSession` | Chrome Custom Tabs | | Sheet container | View controller (page sheet) | Activity (bottom sheet dialog) | | Passkey provider | iCloud Keychain | Google Password Manager (Credential Manager, API 28+) | | Runtime minimum | iOS 17+ | clerk-android's minimum supported API level | `apple_failed` exists on Android too, even though the underlying flow is OAuth rather than the native sheet. ### Input-inert on Android Three optional parameters are accepted on both platforms for schema parity but currently have no native effect on Android due to clerk-android SDK limitations. Passing them on Android is a no-op rather than an error. | Parameter | iOS behavior | Android behavior | | ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | | [`clerk://authview?mode=...`](/authentication/clerk/auth-methods#auth-sheet) | Opens the requested screen (`signin`, `signup`, or `signinorup`) | Always opens the combined sign-in-or-up screen. clerk-android-ui's `AuthView()` does not expose a mode parameter | | [`method=oauth&ephemeral=true`](/authentication/clerk/auth-methods#oauth) | Skips shared browser cookies via `ASWebAuthenticationSession` | Inert. Chrome Custom Tab has no ephemeral session option | | [`method=enterprise_sso&ephemeral=true`](/authentication/clerk/auth-methods#enterprise-sso) | Skips shared browser cookies | Inert. Same SDK limitation as OAuth | ### Custom OAuth providers All 26 named providers in the [OAuth providers](#oauth-providers) list resolve identically on both platforms. An unrecognized provider value (a custom Clerk OAuth provider configured in your dashboard) runs as Clerk's generic `CUSTOM` strategy on Android, the bridge cannot carry the specific provider key. On iOS the exact custom provider key is preserved end-to-end. In both cases the flow runs rather than failing. *** ## Resources despia-native [support@despia.com](mailto:support@despia.com) # Session Source: https://setup.despia.com/authentication/clerk/sessions Check who's signed in, read the auto-refreshed JWT, and authenticate API calls. After [sign-in](/authentication/clerk/auth-methods), the bridge keeps `window.clerkJWT` populated and maintains an in-memory user snapshot you can read offline. *** ## Session State A full snapshot of the signed-in user and active session, read from the in-memory cache hydrated from the secure session store. No network round-trip, works fully offline. Calling it on a signed-in user also restarts the JWT refresher. ```javascript theme={null} despia('clerk://state') ``` Emits when signed in: ```json theme={null} { "ok": true, "event": "state", "isConfigured": true, "isSignedIn": true, "publishableKey": "pk_test_abc123", "userId": "user_2abcDEF", "emailAddress": "jane@example.com", "firstName": "Jane", "lastName": "Doe", "username": "jdoe", "imageUrl": "https://img.clerk.com/abc.png", "user": { "id": "user_2abcDEF", "imageUrl": "https://img.clerk.com/abc.png", "hasImage": true, "passwordEnabled": true, "twoFactorEnabled": false, "totpEnabled": false, "backupCodeEnabled": false, "hasVerifiedEmailAddress": true, "hasVerifiedPhoneNumber": false, "createdAt": "2025-11-12T08:14:33.221Z", "updatedAt": "2026-05-14T19:02:11.118Z", "firstName": "Jane", "lastName": "Doe", "username": "jdoe", "lastSignInAt": "2026-05-14T19:02:11.118Z", "primaryEmailAddressId": "idn_2eml", "primaryPhoneNumberId": "idn_2phn", "primaryEmailAddress": "jane@example.com", "primaryPhoneNumber": "+15551234567", "emailAddresses": [ { "id": "idn_2eml", "emailAddress": "jane@example.com", "verified": true, "verificationStatus": "verified" } ], "phoneNumbers": [ { "id": "idn_2phn", "phoneNumber": "+15551234567", "verified": true, "verificationStatus": "verified", "defaultSecondFactor": false, "reservedForSecondFactor": false } ], "externalAccounts": [ { "id": "idn_2ext", "provider": "oauth_google", "providerUserId": "108422", "emailAddress": "jane@example.com", "approvedScopes": "email profile openid", "firstName": "Jane", "lastName": "Doe", "imageUrl": "https://lh3.googleusercontent.com/x", "username": "jane", "label": null, "verificationStatus": "verified" } ], "publicMetadata": { "plan": "pro" }, "unsafeMetadata": { "onboarded": true } }, "session": { "id": "sess_2xyz", "status": "active", "expireAt": "2026-05-21T19:02:11.118Z", "abandonAt": "2026-06-13T19:02:11.118Z", "lastActiveAt": "2026-05-14T19:02:11.118Z", "createdAt": "2026-05-14T19:02:11.118Z", "updatedAt": "2026-05-14T19:02:11.118Z" } } ``` Optional fields are omitted when absent. Timestamps are ISO-8601 with fractional seconds. When signed out: ```json theme={null} { "ok": true, "event": "state", "isConfigured": true, "isSignedIn": false, "publishableKey": "pk_test_abc123" } ``` When not configured: ```json theme={null} { "ok": true, "event": "state", "isConfigured": false, "isSignedIn": false } ``` *** ## The JWT `window.clerkJWT` holds the current session token, auto-refreshed every 50 seconds (under Clerk's 60-second TTL). Attach it as a Bearer token. ```javascript theme={null} async function api(path, opts = {}) { return fetch(path, { ...opts, headers: { ...opts.headers, Authorization: 'Bearer ' + window.clerkJWT }, }) } ``` The refresher de-duplicates writes, so watchers on `window.clerkJWT` don't see spurious churn. Failed refreshes (offline) leave the last good value in place rather than nulling it. Refresher starts on configure, reconfigure, `clerk://state` while signed in, and any sign-in flow reaching `complete`. Stops on sign-out and reconfigure. When the app returns from the background, the bridge automatically refreshes the JWT (and re-syncs SSR cookies) before your code runs again, so a backgrounded app comes back with a fresh token. *** ## Force a refresh An on-demand JWT refresh, same source as the background refresher. Use this only when you need a guaranteed-fresh token right now. ```javascript theme={null} despia('clerk://token') ``` ```json theme={null} { "ok": true, "event": "token", "jwt": "eyJhbGciOiJSUzI1Ni..." } ``` `jwt` is `null` when there is no session. Errors emit with `event: "token"`: `not_configured`, `token_fetch_failed` (window\.clerkJWT keeps last value), `unsupported_os`, `sdk_not_linked`. *** ## Handling 401 Force a refresh and retry once. ```javascript theme={null} async function api(path) { let res = await fetch(path, { headers: { Authorization: 'Bearer ' + window.clerkJWT } }) if (res.status === 401) { despia('clerk://token') await new Promise((r) => setTimeout(r, 200)) res = await fetch(path, { headers: { Authorization: 'Bearer ' + window.clerkJWT } }) } return res } ``` On the server, verify the JWT with `@clerk/backend` and trust only the verified `sub` claim. Never trust a client-reported `userId`. For middleware-based auth on Next.js, TanStack, and other frameworks, see [Server-Side Auth](/authentication/clerk/backend). *** ## Offline Routes that work offline: `clerk://state` (in-memory cache) and `window.clerkJWT` reads (cached, refreshed in the background). Routes that need network: `clerk://token`, all sign-in flows, OAuth, `clerk://signout`. ```javascript theme={null} const isDespia = navigator.userAgent.toLowerCase().includes('despia') if (isDespia) { window.onClerkEvent = (r) => { if (r.event === 'ready') despia('clerk://state') if (r.event === 'state' && r.isSignedIn) renderApp(r.user) } despia('clerk://configure?key=pk_test_abc123') } ``` *** ## Resources despia-native [support@despia.com](mailto:support@despia.com) # Test Source: https://setup.despia.com/authentication/payments/test # Test This is a test page. # User Session Source: https://setup.despia.com/best-practices/backend/one-signal/user-session This guide covers user identity management for push notifications using OneSignal in Despia apps. ## Overview Push notifications require linking each user to a unique identifier (`app_user_id`) that persists across sessions and devices. This identifier connects OneSignal, RevenueCat, and your backend database. ```mermaid theme={null} flowchart LR ID[app_user_id] --> OS[OneSignal] ID --> RC[RevenueCat] ID --> DB[(Your Database)] ``` The same `app_user_id` must be used consistently across all services to ensure users receive the correct notifications. *** ## Identity Resolution On every app launch, resolve the user's identity using the following priority: | Priority | Source | Description | | :------- | :---------------- | :----------------------------------------- | | 1 | Storage Vault | Synced via iCloud/Google backup | | 2 | Restore Purchases | Recovers paying users via `externalUserId` | | 3 | Install ID | Fallback for new users (`despia.uuid`) | ```mermaid theme={null} flowchart TD Start([App Launch]) --> GetUUID[Get install_id from despia.uuid] GetUUID --> Vault[Read from Storage Vault] Vault --> VaultCheck{Found?} VaultCheck -->|Yes| UseVault[Use vault ID] VaultCheck -->|No| Restore[Query purchase history] Restore --> RestoreCheck{Found externalUserId?} RestoreCheck -->|Yes| UseRestore[Use restored ID] RestoreCheck -->|No| UseInstall[Use install_id] UseVault --> Persist[Persist to vault] UseRestore --> Lock[Lock identity] Lock --> Persist UseInstall --> Persist Persist --> SyncOS[Sync to OneSignal] SyncOS --> SyncDB[Sync to database] SyncDB --> Complete([Complete]) ``` *** ## Implementation ### App Startup Execute this logic on every app launch: ```javascript theme={null} import despia from 'despia-native'; async function initializeIdentity() { const ua = navigator.userAgent.toLowerCase(); if (!ua.includes('despia')) { return null; } const installId = despia.uuid; let appUserId = null; let source = 'new'; // Step 1: Attempt to read from Storage Vault try { const vaultData = await despia('readvault://?key=app_user_id', ['app_user_id']); if (vaultData?.app_user_id) { appUserId = vaultData.app_user_id; source = 'vault'; } } catch (error) { // Vault read failed, continue to next source } // Step 2: Attempt to recover from purchase history if (!appUserId) { try { const data = await despia('getpurchasehistory://', ['restoredData']); const purchases = data.restoredData || []; // Prioritize active subscriptions, then most recent purchase const activePurchase = purchases.find(p => p.isActive && p.externalUserId); const recentPurchase = purchases .filter(p => p.externalUserId) .sort((a, b) => new Date(b.purchaseDate) - new Date(a.purchaseDate))[0]; const recoveredId = activePurchase?.externalUserId || recentPurchase?.externalUserId; if (recoveredId) { appUserId = recoveredId; source = 'restore'; // Lock identity to prevent accidental changes await despia('setvault://?key=identity_locked&value=true&locked=false'); } } catch (error) { // Purchase history unavailable, continue to fallback } } // Step 3: Fall back to install ID for new users if (!appUserId) { appUserId = installId; source = 'new'; } // Step 4: Persist to Storage Vault try { await despia(`setvault://?key=app_user_id&value=${appUserId}&locked=false`); } catch (error) { // Vault write failed, continue with sync } // Step 5: Sync to OneSignal despia(`setonesignalplayerid://?user_id=${appUserId}`); // Step 6: Sync to backend database try { await fetch('/api/user/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ appUserId, deviceId: installId, source, platform: ua.includes('android') ? 'android' : 'ios', timestamp: new Date().toISOString() }) }); } catch (error) { // Queue for retry on next launch const queue = JSON.parse(localStorage.getItem('identity_sync_queue') || '[]'); queue.push({ appUserId, installId, source, queuedAt: Date.now() }); localStorage.setItem('identity_sync_queue', JSON.stringify(queue)); } return { appUserId, installId, source }; } ``` **Important:** The `setonesignalplayerid://` protocol must be called on every app launch, not only during first install. Users who were active before push notifications were implemented will not trigger login flows again. This is the only opportunity to sync their identity to OneSignal. *** ### User Login When a user authenticates, the backend determines whether to claim the current identity or recover an existing one. ```mermaid theme={null} sequenceDiagram autonumber participant App participant API participant Vault participant Push Note over App,Push: Authentication App->>API: POST /login Note over App,API: + currentAppUserId API->>API: Query account Note over App,Push: Identity Resolution alt No app_user_id Note over API: CLAIM API->>API: Save app_user_id API-->>App: action: claimed else Has app_user_id Note over API: RECOVER API-->>App: action: recovered end Note over App,Push: State Sync App->>Vault: Write app_user_id Vault-->>App: OK App->>Push: Set external_user_id Push-->>App: OK Note over App,Push: Done ``` ```javascript theme={null} async function handleUserLogin(accountId, credentials) { const response = await fetch('/api/user/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ accountId, currentAppUserId: appUserId, credentials }) }); if (!response.ok) { throw new Error('Login failed'); } const { appUserId: returnedId, action } = await response.json(); // Update Storage Vault await despia(`setvault://?key=app_user_id&value=${returnedId}&locked=false`); // Sync to OneSignal despia(`setonesignalplayerid://?user_id=${returnedId}`); // Update local state appUserId = returnedId; return { appUserId: returnedId, action }; } ``` *** ### User Logout On logout, generate a new anonymous identifier. Do not reuse the device's `install_id` to prevent identity collision between users on shared devices. ```javascript theme={null} async function handleUserLogout() { // Generate a unique anonymous identifier const newAnonymousId = `${despia.uuid}-${Date.now()}`; // Clear identity lock await despia('setvault://?key=identity_locked&value=false&locked=false'); // Write new identity to vault await despia(`setvault://?key=app_user_id&value=${newAnonymousId}&locked=false`); // Sync to OneSignal despia(`setonesignalplayerid://?user_id=${newAnonymousId}`); // Register new anonymous user with backend try { await fetch('/api/user/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ appUserId: newAnonymousId, deviceId: despia.uuid, source: 'logout', timestamp: new Date().toISOString() }) }); } catch (error) { // Queue for retry const queue = JSON.parse(localStorage.getItem('identity_sync_queue') || '[]'); queue.push({ appUserId: newAnonymousId, installId: despia.uuid, source: 'logout', queuedAt: Date.now() }); localStorage.setItem('identity_sync_queue', JSON.stringify(queue)); } // Update local state appUserId = newAnonymousId; return newAnonymousId; } ``` *** ## Push Permission ### Automatic Mode (Default) When Manual Push Permission is disabled in Despia settings, the permission prompt displays automatically on first launch. No additional code is required. ### Manual Mode Enable Manual Push Permission in Despia settings to control when the prompt appears: ```javascript theme={null} // Request push permission async function requestPushPermission() { await despia('registerpush://'); // Verify permission status const status = await despia('checkNativePushPermissions://', ['nativePushEnabled']); return status.nativePushEnabled; } // Check current permission status async function checkPushPermission() { const status = await despia('checkNativePushPermissions://', ['nativePushEnabled']); return status.nativePushEnabled; } // Direct user to system settings (for users who denied permission) function openSystemSettings() { despia('settingsapp://'); } ``` *** ## Backend Implementation ### Send Push Notification ```mermaid theme={null} sequenceDiagram autonumber participant API participant Push participant Device Note over API,Device: Push Notification API->>Push: POST /notifications Note over API,Push: external_id = app_user_id Push->>Device: Deliver Device-->>Push: Received ``` #### Single User ```javascript theme={null} const ONESIGNAL_APP_ID = process.env.ONESIGNAL_APP_ID; const ONESIGNAL_REST_API_KEY = process.env.ONESIGNAL_REST_API_KEY; async function sendPushNotification(appUserId, title, message, data = {}) { const response = await fetch('https://onesignal.com/api/v1/notifications', { method: 'POST', headers: { 'Authorization': `Basic ${ONESIGNAL_REST_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ app_id: ONESIGNAL_APP_ID, include_aliases: { external_id: [appUserId] }, target_channel: 'push', headings: { en: title }, contents: { en: message }, data }) }); if (!response.ok) { throw new Error(`OneSignal API error: ${response.status}`); } return response.json(); } ``` #### Multiple Users ```javascript theme={null} async function sendPushNotificationToMany(appUserIds, title, message, data = {}) { const response = await fetch('https://onesignal.com/api/v1/notifications', { method: 'POST', headers: { 'Authorization': `Basic ${ONESIGNAL_REST_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ app_id: ONESIGNAL_APP_ID, include_aliases: { external_id: appUserIds }, target_channel: 'push', headings: { en: title }, contents: { en: message }, data }) }); return response.json(); } ``` #### All Users ```javascript theme={null} async function sendPushNotificationToAll(title, message, data = {}) { const response = await fetch('https://onesignal.com/api/v1/notifications', { method: 'POST', headers: { 'Authorization': `Basic ${ONESIGNAL_REST_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ app_id: ONESIGNAL_APP_ID, included_segments: ['All'], headings: { en: title }, contents: { en: message }, data }) }); return response.json(); } ``` *** ### User Registration Endpoint ```javascript theme={null} // POST /api/user/register async function handleUserRegistration(req, res) { const { appUserId, deviceId, source, platform, timestamp } = req.body; if (!appUserId || !deviceId) { return res.status(400).json({ error: 'appUserId and deviceId are required' }); } try { await db.query(` INSERT INTO app_users ( app_user_id, device_id, platform, source, created_at, last_seen ) VALUES ($1, $2, $3, $4, $5, $5) ON CONFLICT (app_user_id) DO UPDATE SET device_id = EXCLUDED.device_id, platform = EXCLUDED.platform, last_seen = EXCLUDED.last_seen `, [appUserId, deviceId, platform, source, timestamp || new Date()]); return res.json({ success: true, appUserId }); } catch (error) { console.error('User registration error:', error); return res.status(500).json({ error: 'Registration failed' }); } } ``` *** ### User Login Endpoint Use row-level locking to prevent race conditions when multiple devices authenticate simultaneously. ```javascript theme={null} // POST /api/user/login async function handleUserLogin(req, res) { const client = await db.connect(); try { const { accountId, currentAppUserId, credentials } = req.body; // Validate credentials here // ... await client.query('BEGIN'); // Lock the account row to prevent concurrent modifications const accountResult = await client.query( 'SELECT app_user_id FROM accounts WHERE id = $1 FOR UPDATE', [accountId] ); if (accountResult.rows.length === 0) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'Account not found' }); } const existingAppUserId = accountResult.rows[0].app_user_id; if (existingAppUserId) { // RECOVER: Account already has an app_user_id await client.query('COMMIT'); return res.json({ success: true, action: 'recovered', appUserId: existingAppUserId }); } else { // CLAIM: Assign current app_user_id to account await client.query( 'UPDATE accounts SET app_user_id = $1 WHERE id = $2', [currentAppUserId, accountId] ); await client.query(` UPDATE app_users SET account_id = $1, is_anonymous = false WHERE app_user_id = $2 `, [accountId, currentAppUserId]); await client.query('COMMIT'); return res.json({ success: true, action: 'claimed', appUserId: currentAppUserId }); } } catch (error) { await client.query('ROLLBACK'); console.error('Login error:', error); return res.status(500).json({ error: 'Login failed' }); } finally { client.release(); } } ``` *** ## User-to-User Messaging For direct messaging features, push notifications serve as a fallback when the recipient is not actively using the app. For real-time messaging, consider dedicated services such as Stream or XMPP. ```javascript theme={null} async function notifyDirectMessage(recipientAccountId, senderName, messagePreview) { // Look up recipient's app_user_id const result = await db.query( 'SELECT app_user_id FROM app_users WHERE account_id = $1', [recipientAccountId] ); const recipientAppUserId = result.rows[0]?.app_user_id; if (!recipientAppUserId) { console.log('Recipient has no app_user_id registered'); return; } await fetch('https://onesignal.com/api/v1/notifications', { method: 'POST', headers: { 'Authorization': `Basic ${ONESIGNAL_REST_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ app_id: ONESIGNAL_APP_ID, include_aliases: { external_id: [recipientAppUserId] }, target_channel: 'push', headings: { en: `Message from ${senderName}` }, contents: { en: messagePreview }, data: { type: 'direct_message', screen: 'chat' } }) }); } ``` *** ## Database Schema ```sql theme={null} CREATE TABLE app_users ( id SERIAL PRIMARY KEY, app_user_id VARCHAR(255) NOT NULL UNIQUE, device_id VARCHAR(255), platform VARCHAR(20), source VARCHAR(50), is_anonymous BOOLEAN DEFAULT true, account_id VARCHAR(255), push_enabled BOOLEAN DEFAULT true, created_at TIMESTAMP DEFAULT NOW(), last_seen TIMESTAMP NOT NULL ); CREATE INDEX idx_app_users_app_user_id ON app_users(app_user_id); CREATE INDEX idx_app_users_account_id ON app_users(account_id); CREATE INDEX idx_app_users_last_seen ON app_users(last_seen); -- Add app_user_id column to existing accounts table ALTER TABLE accounts ADD COLUMN app_user_id VARCHAR(255) UNIQUE; CREATE INDEX idx_accounts_app_user_id ON accounts(app_user_id); ``` *** ## Offline Retry Queue Process queued identity syncs on app launch: ```javascript theme={null} async function processRetryQueue() { const queue = JSON.parse(localStorage.getItem('identity_sync_queue') || '[]'); if (queue.length === 0) return; const remaining = []; for (const item of queue) { try { await fetch('/api/user/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ appUserId: item.appUserId, deviceId: item.installId, source: 'retry' }) }); } catch (error) { // Retain items less than 24 hours old if (Date.now() - item.queuedAt < 24 * 60 * 60 * 1000) { remaining.push(item); } } } localStorage.setItem('identity_sync_queue', JSON.stringify(remaining)); } // Call after identity initialization processRetryQueue(); ``` *** ## Protocol Reference | Protocol | Purpose | When to Call | | :----------------------------------- | :------------------------------- | :------------------------------ | | `setonesignalplayerid://?user_id=X` | Set OneSignal external\_user\_id | Every app launch, login, logout | | `readvault://?key=app_user_id` | Read from Storage Vault | App startup | | `setvault://?key=X&value=Y&locked=Z` | Write to Storage Vault | After identity resolution | | `getpurchasehistory://` | Get purchase history | Identity recovery | | `registerpush://` | Request push permission | Manual mode only | | `checkNativePushPermissions://` | Check push permission status | Settings screens | | `settingsapp://` | Open system settings | Re-engage denied users | *** ## Related Documentation * [Storage Vault](https://setup.despia.com/native-features/storage-vault) * [OneSignal Setup](https://setup.despia.com/native-features/onesignal) # Cron Jobs Source: https://setup.despia.com/best-practices/backend/revenuecat/cron-jobs Implement a self-healing subscription sync system using scheduled cron jobs as a backup to webhooks. This ensures your database stays synchronized with RevenueCat even when webhooks fail or are delayed. ## Why You Need Cron-Based Sync ### The Problem with Webhooks Alone Webhooks are the primary sync mechanism, but they can fail: | Failure Scenario | Impact | Probability | | :----------------------------------- | :------------- | :----------------- | | Network timeout during delivery | Webhook lost | Low (\~1-2%) | | Server downtime when webhook arrives | Webhook lost | Low (\~0.5%) | | Database transaction failure | Data not saved | Very Low (\~0.1%) | | Webhook processing bug | Incorrect sync | Low (\~1%) | | Cancellation webhook delay | 2+ hour delay | High (100%) | | RevenueCat retry exhausted | Permanent loss | Very Low (\~0.01%) | **Result:** 1-3% of subscription changes may not sync immediately via webhooks. ### The Solution: Defense in Depth ``` Layer 1: Webhooks (Primary) ─── 97-99% coverage Layer 2: Cron Sync (Backup) ─── Catches remaining 1-3% Layer 3: User-Triggered Sync ─── On-demand verification ``` **Combined reliability: 99.9%+ sync accuracy** *** ## Architecture Overview ```mermaid theme={null} flowchart TD Start([Cron Job Triggers
Every 5 minutes]) Start --> GetUsers[Get Users to Sync
Priority Queue] GetUsers --> CalcBatch{Calculate Batch Size
Based on Rate Limit} CalcBatch --> Priority[Prioritize Users:
1. Expires within 7 days
2. Billing issues
3. Recently cancelled
4. Stale cache 24h+
5. Random sample] Priority --> BatchLoop{More users
in batch?} BatchLoop -->|YES| FetchRC[Fetch from
RevenueCat V2 API] FetchRC --> Compare{Status
matches DB?} Compare -->|NO - Mismatch| SyncDB[Sync Database
with RevenueCat] Compare -->|YES - Match| LogSuccess[Log: In Sync] SyncDB --> UpdateMetrics[Update Sync Metrics] LogSuccess --> UpdateMetrics UpdateMetrics --> RateLimit[Sleep to respect
rate limit] RateLimit --> BatchLoop BatchLoop -->|NO| Complete[Log Completion
Report Discrepancies] Complete --> End([End]) style Start fill:#e1f5e1 style End fill:#e1e1e1 style FetchRC fill:#fff4e1 style SyncDB fill:#ffe1e1 style CalcBatch fill:#e1f0ff style Compare fill:#ffe1e1 style Priority fill:#f0f0f0 ``` *** ## Rate Limit Calculation ### RevenueCat API Limits **Hard Limit:** 480 requests per minute (8 requests per second) ### Safe Operating Parameters ```javascript theme={null} const RATE_LIMITS = { MAX_REQUESTS_PER_MINUTE: 480, SAFE_BUFFER: 0.7, // Use only 70% of limit (336 req/min) CRON_INTERVAL_MINUTES: 5, REQUESTS_PER_SECOND: 5.6, // 336 / 60 seconds MS_BETWEEN_REQUESTS: 179 // 1000ms / 5.6 }; // Calculate max users per cron run const MAX_USERS_PER_RUN = Math.floor( RATE_LIMITS.MAX_REQUESTS_PER_MINUTE * RATE_LIMITS.SAFE_BUFFER * RATE_LIMITS.CRON_INTERVAL_MINUTES ); // Result: 1,680 users per 5-minute run ``` ### Why 70% Buffer? 1. **Other API usage:** Your app makes real-time API calls 2. **Webhook responses:** RevenueCat may call your webhook handlers 3. **Burst protection:** Avoid hitting rate limit spikes 4. **Multiple services:** Other systems may use the same API key *** ## Implementation ### Core Sync Function ```javascript theme={null} const REVENUECAT_API_KEY = process.env.REVENUECAT_API_KEY; const REVENUECAT_PROJECT_ID = process.env.REVENUECAT_PROJECT_ID; // Rate limit configuration const RATE_LIMIT = { MAX_PER_MINUTE: 480, SAFE_BUFFER: 0.7, SAFE_REQUESTS_PER_MINUTE: 480 * 0.7, // 336 MS_BETWEEN_REQUESTS: (60 * 1000) / (480 * 0.7), // ~179ms MAX_PER_5MIN_RUN: 336 * 5 // 1,680 users }; /** * Main cron job function * Runs every 5 minutes */ async function syncSubscriptionsWithRevenueCat() { const startTime = Date.now(); console.log(' Starting subscription sync job...'); try { // Step 1: Get users to sync (prioritized) const usersToSync = await getUsersForSync(RATE_LIMIT.MAX_PER_5MIN_RUN); console.log(`Found ${usersToSync.length} users to sync`); // Step 2: Process in batches with rate limiting const results = { total: usersToSync.length, synced: 0, matched: 0, discrepancies: 0, errors: 0, notFound: 0 }; for (let i = 0; i < usersToSync.length; i++) { const user = usersToSync[i]; try { // Fetch from RevenueCat V2 const rcData = await fetchCustomerDataV2( user.app_user_id, REVENUECAT_PROJECT_ID ); if (!rcData) { results.notFound++; await logSyncResult(user.app_user_id, 'not_found'); continue; } // Compare with database const hasDiscrepancy = await detectDiscrepancy(user, rcData); if (hasDiscrepancy) { // Sync database with RevenueCat await syncDatabaseWithRevenueCat(user.app_user_id, rcData); results.discrepancies++; results.synced++; console.log(` Synced ${user.app_user_id} - discrepancy found`); await logSyncResult(user.app_user_id, 'synced', hasDiscrepancy); } else { results.matched++; await logSyncResult(user.app_user_id, 'matched'); } } catch (error) { results.errors++; console.error(` Error syncing ${user.app_user_id}:`, error.message); await logSyncError(user.app_user_id, error); } // Rate limiting: sleep between requests if (i < usersToSync.length - 1) { await sleep(RATE_LIMIT.MS_BETWEEN_REQUESTS); } // Progress logging every 100 users if ((i + 1) % 100 === 0) { console.log(`Progress: ${i + 1}/${usersToSync.length} users processed`); } } // Step 3: Log completion const duration = ((Date.now() - startTime) / 1000).toFixed(2); console.log(' Sync job completed', { duration: `${duration}s`, ...results, discrepancyRate: `${((results.discrepancies / results.total) * 100).toFixed(2)}%` }); // Step 4: Alert if discrepancy rate is high const discrepancyRate = results.discrepancies / results.total; if (discrepancyRate > 0.05) { await sendAlert('High subscription discrepancy rate', { rate: `${(discrepancyRate * 100).toFixed(2)}%`, total: results.total, discrepancies: results.discrepancies }); } // Step 5: Store metrics await storeSyncMetrics({ timestamp: new Date(), duration_seconds: duration, ...results }); return results; } catch (error) { console.error(' Sync job failed:', error); await sendAlert('Subscription sync job failed', { error: error.message }); throw error; } } /** * Get users for sync - prioritized queue */ async function getUsersForSync(limit) { // Priority 1: Subscriptions expiring within 7 days const expiringUsers = await db.query(` SELECT app_user_id, is_active, expires_at, last_revenuecat_sync_at FROM user_subscriptions WHERE is_active = true AND expires_at IS NOT NULL AND expires_at BETWEEN NOW() AND NOW() + INTERVAL '7 days' ORDER BY expires_at ASC LIMIT $1 `, [Math.floor(limit * 0.3)]); // 30% of quota // Priority 2: Billing issues const billingIssueUsers = await db.query(` SELECT app_user_id, is_active, expires_at, last_revenuecat_sync_at FROM user_subscriptions WHERE billing_issue = true AND is_active = true ORDER BY last_updated ASC LIMIT $1 `, [Math.floor(limit * 0.2)]); // 20% of quota // Priority 3: Recently cancelled (within 48 hours) const recentlyCancelledUsers = await db.query(` SELECT app_user_id, is_active, expires_at, last_revenuecat_sync_at FROM user_subscriptions WHERE subscription_status = 'cancelled' AND cancelled_at > NOW() - INTERVAL '48 hours' ORDER BY cancelled_at DESC LIMIT $1 `, [Math.floor(limit * 0.2)]); // 20% of quota // Priority 4: Stale cache (not synced in 24+ hours) const staleCacheUsers = await db.query(` SELECT app_user_id, is_active, expires_at, last_revenuecat_sync_at FROM user_subscriptions WHERE (last_revenuecat_sync_at IS NULL OR last_revenuecat_sync_at < NOW() - INTERVAL '24 hours') AND is_active = true ORDER BY last_revenuecat_sync_at ASC NULLS FIRST LIMIT $1 `, [Math.floor(limit * 0.2)]); // 20% of quota // Priority 5: Random sample of active users const randomUsers = await db.query(` SELECT app_user_id, is_active, expires_at, last_revenuecat_sync_at FROM user_subscriptions WHERE is_active = true ORDER BY RANDOM() LIMIT $1 `, [Math.floor(limit * 0.1)]); // 10% of quota // Combine and deduplicate const allUsers = [ ...expiringUsers.rows, ...billingIssueUsers.rows, ...recentlyCancelledUsers.rows, ...staleCacheUsers.rows, ...randomUsers.rows ]; // Remove duplicates by app_user_id const uniqueUsers = Array.from( new Map(allUsers.map(user => [user.app_user_id, user])).values() ); return uniqueUsers.slice(0, limit); } /** * Detect discrepancy between database and RevenueCat */ async function detectDiscrepancy(dbUser, rcData) { // Check if active status matches const dbIsActive = dbUser.is_active; const rcIsActive = rcData.active_entitlements?.items?.some( ent => ent.expires_at > Date.now() ); if (dbIsActive !== rcIsActive) { return { type: 'active_status', db: dbIsActive, rc: rcIsActive }; } // Check if entitlements match const dbEntitlements = dbUser.entitlements || {}; const rcEntitlements = {}; rcData.active_entitlements?.items?.forEach(ent => { if (ent.expires_at > Date.now()) { rcEntitlements[ent.entitlement_id] = ent.expires_at; } }); const dbEntIds = Object.keys(dbEntitlements).sort(); const rcEntIds = Object.keys(rcEntitlements).sort(); if (JSON.stringify(dbEntIds) !== JSON.stringify(rcEntIds)) { return { type: 'entitlements', db: dbEntIds, rc: rcEntIds }; } // Check if expiration dates match (within 1 minute tolerance) if (dbUser.expires_at && rcData.active_entitlements?.items?.length > 0) { const dbExpiresMs = new Date(dbUser.expires_at).getTime(); const rcExpiresMs = Math.max( ...rcData.active_entitlements.items.map(ent => ent.expires_at) ); const diffMs = Math.abs(dbExpiresMs - rcExpiresMs); if (diffMs > 60000) { // More than 1 minute difference return { type: 'expiration_date', db: new Date(dbExpiresMs).toISOString(), rc: new Date(rcExpiresMs).toISOString(), diff_minutes: (diffMs / 60000).toFixed(2) }; } } return null; // No discrepancy } /** * Utility: Sleep function for rate limiting */ function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } /** * Log sync result to database */ async function logSyncResult(appUserId, status, discrepancy = null) { await db.query(` INSERT INTO sync_logs ( app_user_id, status, discrepancy, synced_at ) VALUES ($1, $2, $3, NOW()) `, [ appUserId, status, discrepancy ? JSON.stringify(discrepancy) : null ]); } /** * Log sync error */ async function logSyncError(appUserId, error) { await db.query(` INSERT INTO sync_errors ( app_user_id, error_message, error_stack, occurred_at ) VALUES ($1, $2, $3, NOW()) `, [ appUserId, error.message, error.stack ]); } /** * Store sync metrics for monitoring */ async function storeSyncMetrics(metrics) { await db.query(` INSERT INTO sync_metrics ( timestamp, duration_seconds, total_users, synced_count, matched_count, discrepancies_count, errors_count, not_found_count, discrepancy_rate ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) `, [ metrics.timestamp, metrics.duration_seconds, metrics.total, metrics.synced, metrics.matched, metrics.discrepancies, metrics.errors, metrics.notFound, metrics.discrepancies / metrics.total ]); } /** * Send alert for critical issues */ async function sendAlert(title, details) { // TODO: Implement with your alerting system // Examples: Email, Slack, PagerDuty, etc. console.error(` ALERT: ${title}`, details); // Example Slack webhook: // await fetch(process.env.SLACK_WEBHOOK_URL, { // method: 'POST', // headers: { 'Content-Type': 'application/json' }, // body: JSON.stringify({ // text: ` ${title}`, // attachments: [{ text: JSON.stringify(details, null, 2) }] // }) // }); } module.exports = { syncSubscriptionsWithRevenueCat, getUsersForSync, detectDiscrepancy }; ``` *** ## Cron Schedule Configuration ### Node.js with node-cron ```javascript theme={null} const cron = require('node-cron'); const { syncSubscriptionsWithRevenueCat } = require('./sync-job'); // Run every 5 minutes cron.schedule('*/5 * * * *', async () => { console.log(' Cron job triggered:', new Date().toISOString()); try { await syncSubscriptionsWithRevenueCat(); } catch (error) { console.error('Cron job failed:', error); } }); console.log(' Cron job scheduled: Every 5 minutes'); ``` ### System Crontab ``` # Edit crontab crontab -e # Add this line (runs every 5 minutes) */5 * * * * /usr/bin/node /path/to/your/app/sync-job.js >> /var/log/revenuecat-sync.log 2>&1 ``` ### Vercel Cron (Next.js) ```javascript theme={null} // vercel.json { "crons": [ { "path": "/api/cron/sync-subscriptions", "schedule": "*/5 * * * *" } ] } // pages/api/cron/sync-subscriptions.js import { syncSubscriptionsWithRevenueCat } from '@/lib/sync-job'; export default async function handler(req, res) { // Verify cron secret if (req.headers.authorization !== `Bearer ${process.env.CRON_SECRET}`) { return res.status(401).json({ error: 'Unauthorized' }); } try { const results = await syncSubscriptionsWithRevenueCat(); return res.json({ success: true, results }); } catch (error) { return res.status(500).json({ error: error.message }); } } ``` ### AWS Lambda with EventBridge ```javascript theme={null} // handler.js exports.handler = async (event) => { const { syncSubscriptionsWithRevenueCat } = require('./sync-job'); try { const results = await syncSubscriptionsWithRevenueCat(); return { statusCode: 200, body: JSON.stringify(results) }; } catch (error) { console.error('Lambda sync failed:', error); return { statusCode: 500, body: JSON.stringify({ error: error.message }) }; } }; // EventBridge rule (CloudFormation/Terraform) // Rate: rate(5 minutes) ``` *** ## Database Schema ### Sync Logs Table ```sql theme={null} CREATE TABLE sync_logs ( id SERIAL PRIMARY KEY, app_user_id VARCHAR(255) NOT NULL, status VARCHAR(50) NOT NULL, -- matched, synced, not_found discrepancy JSONB, synced_at TIMESTAMP DEFAULT NOW() ); CREATE INDEX idx_sync_logs_user ON sync_logs(app_user_id); CREATE INDEX idx_sync_logs_status ON sync_logs(status); CREATE INDEX idx_sync_logs_synced_at ON sync_logs(synced_at); ``` ### Sync Errors Table ```sql theme={null} CREATE TABLE sync_errors ( id SERIAL PRIMARY KEY, app_user_id VARCHAR(255) NOT NULL, error_message TEXT, error_stack TEXT, occurred_at TIMESTAMP DEFAULT NOW() ); CREATE INDEX idx_sync_errors_user ON sync_errors(app_user_id); CREATE INDEX idx_sync_errors_occurred_at ON sync_errors(occurred_at); ``` ### Sync Metrics Table ```sql theme={null} CREATE TABLE sync_metrics ( id SERIAL PRIMARY KEY, timestamp TIMESTAMP NOT NULL, duration_seconds DECIMAL(10, 2), total_users INTEGER, synced_count INTEGER, matched_count INTEGER, discrepancies_count INTEGER, errors_count INTEGER, not_found_count INTEGER, discrepancy_rate DECIMAL(5, 4) ); CREATE INDEX idx_sync_metrics_timestamp ON sync_metrics(timestamp); ``` *** ## Monitoring and Alerts ### Key Metrics to Track ```javascript theme={null} async function getSyncHealthMetrics() { // Last 24 hours const metrics = await db.query(` SELECT COUNT(*) as total_runs, AVG(duration_seconds) as avg_duration, SUM(total_users) as total_users_processed, SUM(discrepancies_count) as total_discrepancies, AVG(discrepancy_rate) as avg_discrepancy_rate, SUM(errors_count) as total_errors FROM sync_metrics WHERE timestamp > NOW() - INTERVAL '24 hours' `); return metrics.rows[0]; } // Alert thresholds const ALERT_THRESHOLDS = { DISCREPANCY_RATE: 0.05, // 5% ERROR_RATE: 0.02, // 2% DURATION_SECONDS: 240, // 4 minutes (should complete in < 5 min) CONSECUTIVE_FAILURES: 3 }; ``` ### Health Check Endpoint ```javascript theme={null} // GET /api/health/sync-job async function handleSyncHealthCheck(req, res) { const metrics = await getSyncHealthMetrics(); const health = { status: 'healthy', metrics, issues: [] }; // Check discrepancy rate if (metrics.avg_discrepancy_rate > ALERT_THRESHOLDS.DISCREPANCY_RATE) { health.status = 'warning'; health.issues.push({ severity: 'warning', message: 'High discrepancy rate detected', value: `${(metrics.avg_discrepancy_rate * 100).toFixed(2)}%` }); } // Check error rate const errorRate = metrics.total_errors / metrics.total_users_processed; if (errorRate > ALERT_THRESHOLDS.ERROR_RATE) { health.status = 'warning'; health.issues.push({ severity: 'warning', message: 'High error rate detected', value: `${(errorRate * 100).toFixed(2)}%` }); } // Check if job ran recently (within 10 minutes) const lastRun = await db.query(` SELECT timestamp FROM sync_metrics ORDER BY timestamp DESC LIMIT 1 `); if (lastRun.rows[0]) { const minutesSinceLastRun = (Date.now() - new Date(lastRun.rows[0].timestamp).getTime()) / 60000; if (minutesSinceLastRun > 10) { health.status = 'unhealthy'; health.issues.push({ severity: 'critical', message: 'Sync job not running', value: `Last run: ${minutesSinceLastRun.toFixed(0)} minutes ago` }); } } return res.json(health); } ``` *** ## Best Practices ### 1. Gradual Rollout Start with a small percentage of users: ```javascript theme={null} // Phase 1: 10% of users const limit = Math.floor(RATE_LIMIT.MAX_PER_5MIN_RUN * 0.1); // Phase 2: 50% of users (after 1 week) const limit = Math.floor(RATE_LIMIT.MAX_PER_5MIN_RUN * 0.5); // Phase 3: 100% of users (after 2 weeks) const limit = RATE_LIMIT.MAX_PER_5MIN_RUN; ``` ### 2. Avoid Peak Hours Skip sync during high-traffic periods: ```javascript theme={null} function shouldSkipSync() { const hour = new Date().getUTCHours(); // Skip 12pm-2pm UTC (peak usage) if (hour >= 12 && hour < 14) { console.log(' Skipping sync during peak hours'); return true; } return false; } async function syncSubscriptionsWithRevenueCat() { if (shouldSkipSync()) { return { skipped: true, reason: 'peak_hours' }; } // ... rest of sync logic } ``` ### 3. Circuit Breaker Stop sync if too many errors occur: ```javascript theme={null} const CIRCUIT_BREAKER = { MAX_CONSECUTIVE_ERRORS: 50, RESET_AFTER_MS: 5 * 60 * 1000 // 5 minutes }; let consecutiveErrors = 0; let circuitOpenedAt = null; async function syncWithCircuitBreaker(user) { // Check if circuit is open if (circuitOpenedAt) { const timeSinceOpened = Date.now() - circuitOpenedAt; if (timeSinceOpened < CIRCUIT_BREAKER.RESET_AFTER_MS) { throw new Error('Circuit breaker open - too many errors'); } else { // Reset circuit circuitOpenedAt = null; consecutiveErrors = 0; } } try { const result = await syncUser(user); consecutiveErrors = 0; // Reset on success return result; } catch (error) { consecutiveErrors++; if (consecutiveErrors >= CIRCUIT_BREAKER.MAX_CONSECUTIVE_ERRORS) { circuitOpenedAt = Date.now(); await sendAlert('Circuit breaker opened', { consecutiveErrors, lastError: error.message }); } throw error; } } ``` ### 4. Exponential Backoff for Rate Limits ```javascript theme={null} async function fetchWithRetry(appUserId, projectId, maxRetries = 3) { for (let attempt = 0; attempt < maxRetries; attempt++) { try { return await fetchCustomerDataV2(appUserId, projectId); } catch (error) { if (error.message.includes('429') || error.message.includes('rate limit')) { const backoffMs = Math.pow(2, attempt) * 1000; // 1s, 2s, 4s console.warn(`Rate limited. Retrying in ${backoffMs}ms...`); await sleep(backoffMs); } else { throw error; } } } throw new Error('Max retries exceeded'); } ``` *** ## Performance Optimization ### Batch Database Queries ```javascript theme={null} // Instead of querying one by one for (const user of users) { const dbUser = await db.query('SELECT * FROM user_subscriptions WHERE app_user_id = $1', [user.app_user_id]); // ... } // Batch query const userIds = users.map(u => u.app_user_id); const dbUsers = await db.query(` SELECT * FROM user_subscriptions WHERE app_user_id = ANY($1) `, [userIds]); const dbUserMap = new Map(dbUsers.rows.map(u => [u.app_user_id, u])); ``` ### Parallel Processing (with rate limit) ```javascript theme={null} const pLimit = require('p-limit'); // Limit to 6 concurrent requests (respects ~6 req/sec) const limit = pLimit(6); const promises = users.map(user => limit(() => syncUser(user)) ); const results = await Promise.allSettled(promises); ``` *** ## Testing ### Manual Test ```javascript theme={null} // Test with single user async function testSync(appUserId) { const user = await db.query( 'SELECT * FROM user_subscriptions WHERE app_user_id = $1', [appUserId] ); const rcData = await fetchCustomerDataV2(appUserId, REVENUECAT_PROJECT_ID); const discrepancy = await detectDiscrepancy(user.rows[0], rcData); console.log('Discrepancy:', discrepancy); if (discrepancy) { await syncDatabaseWithRevenueCat(appUserId, rcData); console.log('Synced!'); } } testSync('user_abc123'); ``` ### Integration Test ```javascript theme={null} // Test full sync job with limited users async function testSyncJob() { // Override limit for testing const originalLimit = RATE_LIMIT.MAX_PER_5MIN_RUN; RATE_LIMIT.MAX_PER_5MIN_RUN = 10; // Test with 10 users const results = await syncSubscriptionsWithRevenueCat(); RATE_LIMIT.MAX_PER_5MIN_RUN = originalLimit; console.log('Test results:', results); } ``` *** ## Troubleshooting ### High Discrepancy Rate **Symptom:** > 5% of syncs show discrepancies **Possible Causes:** 1. Webhooks not being processed 2. Database write failures 3. Race conditions in webhook handler **Solution:** ```javascript theme={null} // Check webhook processing rate const webhookStats = await db.query(` SELECT COUNT(*) as total, COUNT(CASE WHEN processed_at IS NOT NULL THEN 1 END) as processed FROM processed_webhook_events WHERE created_at > NOW() - INTERVAL '24 hours' `); const processingRate = webhookStats.rows[0].processed / webhookStats.rows[0].total; console.log('Webhook processing rate:', `${(processingRate * 100).toFixed(2)}%`); ``` ### Job Taking Too Long **Symptom:** Sync job runs longer than 5 minutes **Solution:** Reduce batch size or increase frequency ```javascript theme={null} // Reduce to 70% capacity const limit = Math.floor(RATE_LIMIT.MAX_PER_5MIN_RUN * 0.7); ``` ### API Rate Limit Errors **Symptom:** Getting 429 errors from RevenueCat **Solution:** Increase sleep time between requests ```dotenv theme={null} RATE_LIMIT.MS_BETWEEN_REQUESTS = 250; // Slower (240 req/min) ``` *** ## Quick Reference ### Sync Job Configuration ```javascript theme={null} const CONFIG = { CRON_SCHEDULE: '*/5 * * * *', // Every 5 minutes BATCH_SIZE: 1680, // Users per run RATE_LIMIT: 336, // Requests per minute (70% of 480) MS_BETWEEN_REQUESTS: 179, // Milliseconds PRIORITY_WEIGHTS: { EXPIRING_SOON: 0.3, // 30% BILLING_ISSUES: 0.2, // 20% RECENTLY_CANCELLED: 0.2, // 20% STALE_CACHE: 0.2, // 20% RANDOM_SAMPLE: 0.1 // 10% } }; ``` ### Alert Thresholds ```javascript theme={null} const ALERTS = { DISCREPANCY_RATE: 0.05, // > 5% ERROR_RATE: 0.02, // > 2% MAX_DURATION: 240, // > 4 minutes STALE_JOB: 10 // No run in 10 minutes }; ``` ### Priority Queue Logic 1. **Expires within 7 days** (30%) - Catch expiring subscriptions 2. **Billing issues** (20%) - Resolve payment problems 3. **Recently cancelled** (20%) - Detect un-cancellations 4. **Stale cache 24h+** (20%) - Refresh old data 5. **Random sample** (10%) - General health check *** ## Summary **Cron job runs every 5 minutes**\ **Processes 1,680 users per run** (respects 70% rate limit buffer)\ **Prioritizes critical users** (expiring, billing issues, etc.)\ **Self-healing** - catches webhook failures automatically\ **Rate-limited** - 179ms between requests\ **Monitored** - tracks discrepancy rates and errors\ **Alerting** - notifies on high discrepancy/error rates **Combined with webhooks: 99.9%+ sync reliability** # User Session Source: https://setup.despia.com/best-practices/backend/revenuecat/user-session This guide covers user identity management for in-app purchases and subscriptions using RevenueCat in Despia apps. ## Overview RevenueCat requires linking each user to a unique identifier (`app_user_id`) that persists across sessions and devices. This identifier connects RevenueCat, OneSignal, and your backend database. ```mermaid theme={null} flowchart LR ID[app_user_id] --> RC[RevenueCat] ID --> OS[OneSignal] ID --> DB[(Your Database)] ``` RevenueCat receives the user identity only when launching a paywall via the `external_id` parameter: ```javascript theme={null} despia(`revenuecat://launchPaywall?external_id=${appUserId}&offering=default`); ``` *** ## Database Synchronization Requirement The user must exist in your database before they can make a purchase. When RevenueCat sends a webhook after a purchase, your backend must be able to locate the user to send push notifications. ### Successful Flow ```mermaid theme={null} sequenceDiagram participant App participant DB as Database participant RC as RevenueCat participant OS as OneSignal App->>DB: POST /api/user/register DB-->>App: User created Note over App: Paywall can now open safely App->>RC: Launch paywall with external_id Note over RC: User completes purchase RC->>DB: Webhook: INITIAL_PURCHASE DB->>DB: Find user by app_user_id DB->>OS: Send push notification OS->>App: Notification delivered ``` ### Failed Flow (Database Sync Missing) ```mermaid theme={null} sequenceDiagram participant App participant DB as Database participant RC as RevenueCat App->>DB: POST /api/user/register DB-->>App: Error (network failure) Note over App: Paywall opens without sync App->>RC: Launch paywall with external_id Note over RC: User completes purchase RC->>DB: Webhook: INITIAL_PURCHASE DB->>DB: User not found Note over DB: Cannot send notification ``` *** ## Identity Resolution On every app launch, resolve the user's identity using the following priority: | Priority | Source | Description | | :------- | :---------------- | :----------------------------------------- | | 1 | Storage Vault | Synced via iCloud/Google backup | | 2 | Restore Purchases | Recovers paying users via `externalUserId` | | 3 | Install ID | Fallback for new users (`despia.uuid`) | ```mermaid theme={null} flowchart TD Start([App Launch]) --> GetUUID[Get install_id from despia.uuid] GetUUID --> Vault[Read from Storage Vault] Vault --> VaultCheck{Found?} VaultCheck -->|Yes| UseVault[Use vault ID] VaultCheck -->|No| Restore[Query purchase history] Restore --> RestoreCheck{Found externalUserId?} RestoreCheck -->|Yes| UseRestore[Use restored ID] RestoreCheck -->|No| UseInstall[Use install_id] UseVault --> Persist[Persist to vault] UseRestore --> Lock[Lock identity] Lock --> Persist UseInstall --> Persist Persist --> SyncOS[Sync to OneSignal] SyncOS --> SyncDB[Sync to database] SyncDB --> Ready([Paywall can open]) ``` *** ## Implementation ### App Startup Execute this logic on every app launch. Track both identity resolution and database synchronization status before allowing paywall access. ```javascript theme={null} import despia from 'despia-native'; // State tracking let identityReady = false; let databaseSynced = false; let appUserId = null; let installId = null; async function initializeIdentity() { const ua = navigator.userAgent.toLowerCase(); if (!ua.includes('despia')) { identityReady = true; databaseSynced = true; return null; } installId = despia.uuid; let source = 'new'; // Step 1: Attempt to read from Storage Vault try { const vaultData = await despia('readvault://?key=app_user_id', ['app_user_id']); if (vaultData?.app_user_id) { appUserId = vaultData.app_user_id; source = 'vault'; } } catch (error) { // Vault read failed, continue to next source } // Step 2: Attempt to recover from purchase history if (!appUserId) { try { const data = await despia('getpurchasehistory://', ['restoredData']); const purchases = data.restoredData || []; // Prioritize active subscriptions, then most recent purchase const activePurchase = purchases.find(p => p.isActive && p.externalUserId); const recentPurchase = purchases .filter(p => p.externalUserId) .sort((a, b) => new Date(b.purchaseDate) - new Date(a.purchaseDate))[0]; const recoveredId = activePurchase?.externalUserId || recentPurchase?.externalUserId; if (recoveredId) { appUserId = recoveredId; source = 'restore'; // Lock identity to prevent accidental changes await despia('setvault://?key=identity_locked&value=true&locked=false'); } } catch (error) { // Purchase history unavailable, continue to fallback } } // Step 3: Fall back to install ID for new users if (!appUserId) { appUserId = installId; source = 'new'; } // Step 4: Persist to Storage Vault try { await despia(`setvault://?key=app_user_id&value=${appUserId}&locked=false`); } catch (error) { // Vault write failed, continue with sync } identityReady = true; // Step 5: Sync to OneSignal despia(`setonesignalplayerid://?user_id=${appUserId}`); // Step 6: Sync to backend database (required for webhooks) try { await fetch('/api/user/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ appUserId, deviceId: installId, source, platform: ua.includes('android') ? 'android' : 'ios', timestamp: new Date().toISOString() }) }); databaseSynced = true; } catch (error) { // Queue for retry on next launch const queue = JSON.parse(localStorage.getItem('identity_sync_queue') || '[]'); queue.push({ appUserId, installId, source, queuedAt: Date.now() }); localStorage.setItem('identity_sync_queue', JSON.stringify(queue)); // Allow app to continue; webhook safety net will handle missing users databaseSynced = true; } return { appUserId, installId, source }; } ``` *** ### Launch Paywall Always verify that identity resolution and database synchronization are complete before opening the paywall. ```javascript theme={null} async function launchPaywall(offering = 'default') { // Wait for identity resolution while (!identityReady) { await new Promise(resolve => setTimeout(resolve, 50)); } // Wait for database synchronization while (!databaseSynced) { await new Promise(resolve => setTimeout(resolve, 50)); } // Safe to open paywall despia(`revenuecat://launchPaywall?external_id=${appUserId}&offering=${offering}`); } ``` Example usage in a UI component: ```javascript theme={null} function UpgradeButton({ offering = 'premium' }) { const [loading, setLoading] = useState(false); const handlePress = async () => { setLoading(true); // Wait for identity and sync while (!identityReady || !databaseSynced) { await new Promise(resolve => setTimeout(resolve, 50)); } despia(`revenuecat://launchPaywall?external_id=${appUserId}&offering=${offering}`); setLoading(false); }; return ( ); } ``` *** ### Identity Locking When a user has made purchases, lock their identity to prevent accidental changes that could orphan their purchase history. ```javascript theme={null} // Check if identity is locked on app startup async function checkIdentityLock() { try { const lockData = await despia('readvault://?key=identity_locked', ['identity_locked']); return lockData?.identity_locked === 'true'; } catch (error) { return false; } } // Lock identity after recovering purchases async function lockIdentity() { await despia('setvault://?key=identity_locked&value=true&locked=false'); } // Clear lock on logout async function clearIdentityLock() { await despia('setvault://?key=identity_locked&value=false&locked=false'); } ``` *** ### Check Subscription Status ```javascript theme={null} async function checkSubscriptionStatus() { try { const data = await despia('getpurchasehistory://', ['restoredData']); const purchases = data.restoredData || []; const activeSubscription = purchases.find(p => p.isActive); if (activeSubscription) { return { isSubscribed: true, productId: activeSubscription.productIdentifier, expiresAt: activeSubscription.expirationDate, externalUserId: activeSubscription.externalUserId }; } return { isSubscribed: false }; } catch (error) { console.error('Failed to check subscription status:', error); return { isSubscribed: false, error: error.message }; } } ``` *** ### Restore Purchases Provide a restore purchases button for users who need to recover their subscriptions on a new device. ```javascript theme={null} async function restorePurchases() { try { const data = await despia('getpurchasehistory://', ['restoredData']); const purchases = data.restoredData || []; // Find any purchase with an externalUserId const purchaseWithId = purchases.find(p => p.externalUserId); if (purchaseWithId) { const recoveredId = purchaseWithId.externalUserId; // Update Storage Vault await despia(`setvault://?key=app_user_id&value=${recoveredId}&locked=false`); await despia('setvault://?key=identity_locked&value=true&locked=false'); // Sync to OneSignal despia(`setonesignalplayerid://?user_id=${recoveredId}`); // Update local state appUserId = recoveredId; // Sync to backend await fetch('/api/user/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ appUserId: recoveredId, deviceId: installId, source: 'restore_button', timestamp: new Date().toISOString() }) }); return { success: true, appUserId: recoveredId }; } return { success: false, message: 'No purchases found' }; } catch (error) { return { success: false, error: error.message }; } } ``` *** ### User Login ```javascript theme={null} async function handleUserLogin(accountId, credentials) { const response = await fetch('/api/user/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ accountId, currentAppUserId: appUserId, credentials }) }); if (!response.ok) { throw new Error('Login failed'); } const { appUserId: returnedId, action } = await response.json(); // Update Storage Vault await despia(`setvault://?key=app_user_id&value=${returnedId}&locked=false`); // Sync to OneSignal despia(`setonesignalplayerid://?user_id=${returnedId}`); // Update local state appUserId = returnedId; return { appUserId: returnedId, action }; } ``` *** ### User Logout ```javascript theme={null} async function handleUserLogout() { // Generate a unique anonymous identifier const newAnonymousId = `${despia.uuid}-${Date.now()}`; // Clear identity lock await despia('setvault://?key=identity_locked&value=false&locked=false'); // Write new identity to vault await despia(`setvault://?key=app_user_id&value=${newAnonymousId}&locked=false`); // Sync to OneSignal despia(`setonesignalplayerid://?user_id=${newAnonymousId}`); // Register new anonymous user with backend await fetch('/api/user/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ appUserId: newAnonymousId, deviceId: installId, source: 'logout', timestamp: new Date().toISOString() }) }); // Update local state appUserId = newAnonymousId; return newAnonymousId; } ``` *** ## Backend Implementation ### Webhook Handler RevenueCat sends webhooks for subscription events. Your handler must be idempotent and include a safety net for missing users. ```javascript theme={null} const ONESIGNAL_APP_ID = process.env.ONESIGNAL_APP_ID; const ONESIGNAL_REST_API_KEY = process.env.ONESIGNAL_REST_API_KEY; // POST /api/webhooks/revenuecat async function handleRevenueCatWebhook(req, res) { const { event } = req.body; const eventId = event.id; const eventType = event.type; const appUserId = event.app_user_id; // Idempotency check: skip if already processed const existingEvent = await db.query( 'SELECT 1 FROM processed_webhook_events WHERE event_id = $1', [eventId] ); if (existingEvent.rows.length > 0) { return res.json({ success: true, message: 'Event already processed' }); } // Safety net: create user if missing await db.query(` INSERT INTO app_users (app_user_id, source, created_at, last_seen) VALUES ($1, 'webhook_safety_net', NOW(), NOW()) ON CONFLICT (app_user_id) DO NOTHING `, [appUserId]); // Update subscription status const isActive = ['INITIAL_PURCHASE', 'RENEWAL', 'PRODUCT_CHANGE', 'UNCANCELLATION'] .includes(eventType); await db.query(` INSERT INTO subscriptions ( app_user_id, status, product_id, expires_at, billing_issue, updated_at ) VALUES ($1, $2, $3, $4, $5, NOW()) ON CONFLICT (app_user_id) DO UPDATE SET status = EXCLUDED.status, product_id = EXCLUDED.product_id, expires_at = EXCLUDED.expires_at, billing_issue = EXCLUDED.billing_issue, updated_at = NOW() `, [ appUserId, eventType.toLowerCase(), event.product_id, event.expiration_at_ms ? new Date(event.expiration_at_ms) : null, eventType === 'BILLING_ISSUE' ]); // Send push notification await sendSubscriptionNotification(appUserId, eventType); // Mark event as processed await db.query(` INSERT INTO processed_webhook_events (event_id, event_type, app_user_id, processed_at) VALUES ($1, $2, $3, NOW()) `, [eventId, eventType, appUserId]); return res.json({ success: true }); } async function sendSubscriptionNotification(appUserId, eventType) { const notifications = { 'INITIAL_PURCHASE': { title: 'Welcome to Premium', message: 'Thank you for subscribing. Enjoy all premium features.' }, 'RENEWAL': { title: 'Subscription Renewed', message: 'Your subscription has been renewed successfully.' }, 'CANCELLATION': { title: 'Subscription Cancelled', message: 'Your subscription will remain active until the end of the billing period.' }, 'UNCANCELLATION': { title: 'Subscription Reactivated', message: 'Your subscription has been reactivated.' }, 'BILLING_ISSUE': { title: 'Payment Issue', message: 'There was a problem processing your payment. Please update your payment method.' }, 'EXPIRATION': { title: 'Subscription Expired', message: 'Your premium access has ended. Resubscribe to continue enjoying all features.' } }; const notification = notifications[eventType]; if (!notification) return; await fetch('https://onesignal.com/api/v1/notifications', { method: 'POST', headers: { 'Authorization': `Basic ${ONESIGNAL_REST_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ app_id: ONESIGNAL_APP_ID, include_aliases: { external_id: [appUserId] }, target_channel: 'push', headings: { en: notification.title }, contents: { en: notification.message }, data: { type: eventType, screen: 'subscription' } }) }); } ``` *** ### Webhook Event Types | Event Type | Description | Subscription Active | | :----------------- | :--------------------------------------- | :------------------ | | `INITIAL_PURCHASE` | First subscription purchase | Yes | | `RENEWAL` | Subscription renewed | Yes | | `PRODUCT_CHANGE` | Upgraded or downgraded | Yes | | `CANCELLATION` | User cancelled (active until period end) | Yes | | `UNCANCELLATION` | User re-enabled auto-renew | Yes | | `BILLING_ISSUE` | Payment failed | Yes (grace period) | | `EXPIRATION` | Subscription ended | No | | `TRANSFER` | Transferred to different user | Varies | *** ### User Registration Endpoint ```javascript theme={null} // POST /api/user/register async function handleUserRegistration(req, res) { const { appUserId, deviceId, source, platform, timestamp } = req.body; if (!appUserId || !deviceId) { return res.status(400).json({ error: 'appUserId and deviceId are required' }); } try { await db.query(` INSERT INTO app_users ( app_user_id, device_id, platform, source, created_at, last_seen ) VALUES ($1, $2, $3, $4, $5, $5) ON CONFLICT (app_user_id) DO UPDATE SET device_id = EXCLUDED.device_id, platform = EXCLUDED.platform, last_seen = EXCLUDED.last_seen `, [appUserId, deviceId, platform, source, timestamp || new Date()]); return res.json({ success: true, appUserId }); } catch (error) { console.error('User registration error:', error); return res.status(500).json({ error: 'Registration failed' }); } } ``` *** ### User Login Endpoint Use row-level locking to prevent race conditions when multiple devices authenticate simultaneously. ```javascript theme={null} // POST /api/user/login async function handleUserLogin(req, res) { const client = await db.connect(); try { const { accountId, currentAppUserId, credentials } = req.body; // Validate credentials here // ... await client.query('BEGIN'); // Lock the account row to prevent concurrent modifications const accountResult = await client.query( 'SELECT app_user_id FROM accounts WHERE id = $1 FOR UPDATE', [accountId] ); if (accountResult.rows.length === 0) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'Account not found' }); } const existingAppUserId = accountResult.rows[0].app_user_id; if (existingAppUserId) { // RECOVER: Account already has an app_user_id await client.query('COMMIT'); return res.json({ success: true, action: 'recovered', appUserId: existingAppUserId }); } else { // CLAIM: Assign current app_user_id to account await client.query( 'UPDATE accounts SET app_user_id = $1 WHERE id = $2', [currentAppUserId, accountId] ); await client.query(` UPDATE app_users SET account_id = $1, is_anonymous = false WHERE app_user_id = $2 `, [accountId, currentAppUserId]); await client.query('COMMIT'); return res.json({ success: true, action: 'claimed', appUserId: currentAppUserId }); } } catch (error) { await client.query('ROLLBACK'); console.error('Login error:', error); return res.status(500).json({ error: 'Login failed' }); } finally { client.release(); } } ``` *** ## Database Schema ```sql theme={null} CREATE TABLE app_users ( id SERIAL PRIMARY KEY, app_user_id VARCHAR(255) NOT NULL UNIQUE, device_id VARCHAR(255), platform VARCHAR(20), source VARCHAR(50), is_anonymous BOOLEAN DEFAULT true, account_id VARCHAR(255), created_at TIMESTAMP DEFAULT NOW(), last_seen TIMESTAMP NOT NULL ); CREATE INDEX idx_app_users_app_user_id ON app_users(app_user_id); CREATE INDEX idx_app_users_account_id ON app_users(account_id); CREATE TABLE subscriptions ( id SERIAL PRIMARY KEY, app_user_id VARCHAR(255) NOT NULL UNIQUE, status VARCHAR(50), product_id VARCHAR(255), expires_at TIMESTAMP, billing_issue BOOLEAN DEFAULT false, updated_at TIMESTAMP DEFAULT NOW(), created_at TIMESTAMP DEFAULT NOW() ); CREATE INDEX idx_subscriptions_app_user_id ON subscriptions(app_user_id); CREATE INDEX idx_subscriptions_status ON subscriptions(status); CREATE INDEX idx_subscriptions_expires_at ON subscriptions(expires_at); CREATE TABLE processed_webhook_events ( event_id VARCHAR(255) PRIMARY KEY, event_type VARCHAR(100) NOT NULL, app_user_id VARCHAR(255) NOT NULL, processed_at TIMESTAMP DEFAULT NOW() ); CREATE INDEX idx_processed_events_app_user_id ON processed_webhook_events(app_user_id); -- Add app_user_id column to existing accounts table ALTER TABLE accounts ADD COLUMN app_user_id VARCHAR(255) UNIQUE; CREATE INDEX idx_accounts_app_user_id ON accounts(app_user_id); ``` *** ## Debugging Use this function to inspect the current identity state: ```javascript theme={null} async function debugIdentityState() { console.log('Identity State:'); console.log(' appUserId:', appUserId); console.log(' installId:', installId); console.log(' identityReady:', identityReady); console.log(' databaseSynced:', databaseSynced); // Check vault try { const vaultId = await despia('readvault://?key=app_user_id', ['app_user_id']); const locked = await despia('readvault://?key=identity_locked', ['identity_locked']); console.log('Vault:'); console.log(' app_user_id:', vaultId?.app_user_id); console.log(' identity_locked:', locked?.identity_locked); } catch (error) { console.log('Vault read error:', error); } // Check purchases try { const data = await despia('getpurchasehistory://', ['restoredData']); console.log('Purchases:', data.restoredData); } catch (error) { console.log('Purchase history error:', error); } } ``` *** ## Common Issues | Issue | Cause | Solution | | :------------------------------- | :----------------------------------------- | :-------------------------------- | | Webhook cannot find user | Database sync failed before purchase | Implement webhook safety net | | Wrong user receives notification | Identity changed between sync and purchase | Gate paywall on database sync | | Lost purchases on reinstall | Vault not synced, restore not attempted | Always attempt restore on startup | | Duplicate notifications | Webhook processed multiple times | Use idempotency table | *** ## Protocol Reference | Protocol | Purpose | When to Call | | :---------------------------------------------------- | :---------------------- | :-------------------------------- | | `revenuecat://launchPaywall?external_id=X&offering=Y` | Open paywall | After identity and sync complete | | `getpurchasehistory://` | Get purchase history | Identity recovery, restore button | | `readvault://?key=app_user_id` | Read from Storage Vault | App startup | | `setvault://?key=X&value=Y&locked=Z` | Write to Storage Vault | After identity resolution | | `setonesignalplayerid://?user_id=X` | Set OneSignal ID | Every app launch, login, logout | *** ## Related Documentation * [RevenueCat Webhook Template](https://setup.despia.com/best-practices/backend/revenuecat/webhooks) * [Storage Vault](https://setup.despia.com/native-features/storage-vault) # Webhooks Source: https://setup.despia.com/best-practices/backend/revenuecat/webhooks Complete implementation guide for processing RevenueCat webhooks with authorization validation, event type handling, and entitlement management. ## Webhook Flow Diagram ```mermaid theme={null} flowchart TD Start([Webhook Received]) --> ValidateAuth{Validate
Authorization
Header?} ValidateAuth -->|Invalid| Return401[Return 401
Unauthorized] ValidateAuth -->|Valid| ParseBody[Parse JSON Body] ParseBody --> GetUserID[Extract app_user_id
from event] GetUserID --> GetEntitlements[Extract Active
Entitlements] GetEntitlements --> CheckEventType{Check
Event Type} CheckEventType -->|INITIAL_PURCHASE| InitialPurchase[Process Initial Purchase
- Grant access
- Update user status
- Send welcome email] CheckEventType -->|RENEWAL| Renewal[Process Renewal
- Extend access
- Update billing date
- Log transaction] CheckEventType -->|CANCELLATION| Cancellation[Process Cancellation
- Schedule access removal
- Send survey email
- Update churn metrics] CheckEventType -->|UNCANCELLATION| Uncancellation[Process Uncancellation
- Restore full access
- Update status
- Send confirmation] CheckEventType -->|BILLING_ISSUE| BillingIssue[Process Billing Issue
- Flag account
- Send payment reminder
- Set grace period] CheckEventType -->|EXPIRATION| Expiration[Process Expiration
- Remove access
- Archive user data
- Send win-back email] CheckEventType -->|REFUND| Refund[Process Refund
- Revoke access immediately
- Log refund
- Update analytics] CheckEventType -->|REFUND_REVERSED| RefundReversed[Process Refund Reversal
- Restore access
- Update records
- Notify user] CheckEventType -->|PRODUCT_CHANGE| ProductChange[Process Product Change
- Update tier/features
- Adjust limits
- Log upgrade/downgrade] CheckEventType -->|SUBSCRIPTION_PAUSED| Paused[Process Pause
- Temporarily suspend
- Set resume date
- Update UI access] CheckEventType -->|SUBSCRIPTION_EXTENDED| Extended[Process Extension
- Update expiry date
- Log extension
- Send confirmation] CheckEventType -->|TRIAL_STARTED| TrialStart[Process Trial Start
- Grant trial access
- Set trial end date
- Send onboarding] CheckEventType -->|TRIAL_CANCELLED| TrialCancel[Process Trial Cancel
- Remove trial access
- Send feedback survey
- Update metrics] CheckEventType -->|TRANSFER| Transfer[Process Transfer
- Move subscription
- Update ownership
- Sync across accounts] CheckEventType -->|NON_RENEWING_PURCHASE| NonRenewing[Process Non-Renewing
- Grant time-limited access
- Set expiration
- No auto-renewal] CheckEventType -->|INVOICE_ISSUANCE| Invoice[Process Invoice
- Store invoice data
- Send to accounting
- Update billing] CheckEventType -->|TEMPORARY_ENTITLEMENT| TempEntitlement[Process Temp Entitlement
- Grant temporary access
- Set auto-expiry
- Log store issue] CheckEventType -->|VIRTUAL_CURRENCY| VirtualCurrency[Process Virtual Currency
- Update balance
- Log transaction
- Sync wallet] CheckEventType -->|EXPERIMENT_ENROLLMENT| Experiment[Process Experiment
- Assign variant
- Track enrollment
- Update analytics] CheckEventType -->|Unknown Event| UnknownEvent[Log Unknown Event
- Store for review
- Alert team
- Continue gracefully] InitialPurchase --> SyncDatabase[Sync to Database] Renewal --> SyncDatabase Cancellation --> SyncDatabase Uncancellation --> SyncDatabase BillingIssue --> SyncDatabase Expiration --> SyncDatabase Refund --> SyncDatabase RefundReversed --> SyncDatabase ProductChange --> SyncDatabase Paused --> SyncDatabase Extended --> SyncDatabase TrialStart --> SyncDatabase TrialCancel --> SyncDatabase Transfer --> SyncDatabase NonRenewing --> SyncDatabase Invoice --> SyncDatabase TempEntitlement --> SyncDatabase VirtualCurrency --> SyncDatabase Experiment --> SyncDatabase UnknownEvent --> SyncDatabase SyncDatabase --> OptionalFetch{Need Full
Customer Data?} OptionalFetch -->|Yes| FetchAPI[Call GET /subscribers
API for complete data] OptionalFetch -->|No| Return200 FetchAPI --> Return200[Return 200 OK] Return401 --> End([End]) Return200 --> End style Start fill:#e1f5e1 style Return200 fill:#e1f5e1 style Return401 fill:#ffe1e1 style End fill:#e1e1e1 style ValidateAuth fill:#fff4e1 style CheckEventType fill:#e1f0ff ``` *** ## JavaScript Implementation ### Complete Webhook Handler ```javascript theme={null} // Environment configuration const WEBHOOK_SECRET = process.env.REVENUECAT_WEBHOOK_SECRET || 'MYSECRET'; const REVENUECAT_API_KEY = process.env.REVENUECAT_API_KEY; /** * Main webhook handler * POST /webhooks/revenuecat */ async function handleRevenueCatWebhook(req, res) { try { // Step 1: Validate Authorization Header if (!validateAuthorization(req)) { console.warn('Unauthorized webhook attempt'); return res.status(401).json({ error: 'Unauthorized' }); } // Step 2: Parse webhook body const event = req.body?.event; if (!event) { console.error('Invalid webhook body - missing event'); return res.status(400).json({ error: 'Invalid webhook body' }); } // Step 3: Extract key information const eventType = event.type; const appUserId = event.app_user_id; const environment = event.environment; // PRODUCTION or SANDBOX const eventId = event.id; console.log(`Processing webhook: ${eventType} for user ${appUserId} (${environment})`); // Step 4: Check for duplicate event (idempotency) const isDuplicate = await checkDuplicateEvent(eventId); if (isDuplicate) { console.log(`Duplicate event ${eventId} - already processed`); return res.status(200).json({ message: 'Event already processed' }); } // Step 5: Extract entitlements and subscriber info const activeEntitlements = extractEntitlementsFromEvent(event); const subscriberAttributes = event.subscriber_attributes || {}; const externalUserId = subscriberAttributes.$email?.value || subscriberAttributes.$displayName?.value || appUserId; // Step 6: Process event based on type await processWebhookEvent({ eventType, appUserId, externalUserId, environment, activeEntitlements, subscriberAttributes, event }); // Step 7: Sync entitlements to database await syncEntitlementsToDatabase(appUserId, activeEntitlements, event); // Step 8: Mark event as processed await markEventAsProcessed(eventId); // Step 9: Return success return res.status(200).json({ message: 'Webhook processed successfully' }); } catch (error) { console.error('Webhook processing error:', error); // Return 200 to prevent retries for our internal errors // Return 500 only if we want RevenueCat to retry return res.status(200).json({ message: 'Webhook received but processing failed', error: error.message }); } } /** * Validate Authorization Header * RevenueCat sends: Authorization: Bearer MYSECRET * We validate against configured secret */ function validateAuthorization(req) { const authHeader = req.headers.authorization; if (!authHeader) { return false; } // Strip "Bearer " prefix if present const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : authHeader; return token === WEBHOOK_SECRET; } /** * Process webhook event based on type */ async function processWebhookEvent({ eventType, appUserId, externalUserId, environment, activeEntitlements, event }) { // Extract critical entitlement data from webhook const entitlementData = extractEntitlementData(event); // Always sync entitlements to database for ALL events // This ensures database stays in sync with RevenueCat await syncEntitlementsToDatabase(appUserId, entitlementData, event); // Get detailed subscriber data if needed for complex processing // const subscriberData = await fetchSubscriberData(appUserId); switch (eventType) { // ========================================== // PURCHASE EVENTS // ========================================== case 'INITIAL_PURCHASE': console.log(`Initial purchase for ${appUserId}`); // TODO: Implement your logic // - Grant access to premium features // - Update user status in database // - Send welcome email // - Track conversion in analytics // - Update subscription tier await handleInitialPurchase({ appUserId, externalUserId, activeEntitlements, productId: event.product_id, price: event.price, currency: event.currency, purchaseDate: event.purchased_at_ms, expirationDate: event.expiration_at_ms, store: event.store, isTrialConversion: event.is_trial_conversion }); break; case 'RENEWAL': console.log(`Renewal for ${appUserId}`); // TODO: Implement your logic // - Extend subscription access // - Update next billing date // - Log successful payment // - Update LTV metrics await handleRenewal({ appUserId, externalUserId, activeEntitlements, expirationDate: event.expiration_at_ms, price: event.price, currency: event.currency }); break; case 'NON_RENEWING_PURCHASE': console.log(`Non-renewing purchase for ${appUserId}`); // TODO: Implement your logic // - Grant time-limited access // - Set expiration date // - No auto-renewal expected await handleNonRenewingPurchase({ appUserId, externalUserId, activeEntitlements, productId: event.product_id, expirationDate: event.expiration_at_ms }); break; // ========================================== // CANCELLATION EVENTS // ========================================== case 'CANCELLATION': console.log(`Cancellation for ${appUserId}`); // TODO: Implement your logic // - Schedule access removal at period end // - Send feedback survey // - Offer win-back discount // - Update churn analytics // - Flag account as "cancelled" await handleCancellation({ appUserId, externalUserId, activeEntitlements, expirationDate: event.expiration_at_ms, cancellationReason: event.cancellation_reason, autoResumeDate: event.auto_resume_at_ms }); break; case 'UNCANCELLATION': console.log(`Uncancellation for ${appUserId}`); // TODO: Implement your logic // - Restore subscription to active state // - Remove cancellation flag // - Send confirmation email await handleUncancellation({ appUserId, externalUserId, activeEntitlements, expirationDate: event.expiration_at_ms }); break; // ========================================== // TRIAL EVENTS // ========================================== case 'TRIAL_STARTED': console.log(`Trial started for ${appUserId}`); // TODO: Implement your logic // - Grant trial access to features // - Set trial end date // - Send onboarding emails // - Track trial starts await handleTrialStarted({ appUserId, externalUserId, activeEntitlements, expirationDate: event.expiration_at_ms, productId: event.product_id }); break; case 'TRIAL_CANCELLED': console.log(`Trial cancelled for ${appUserId}`); // TODO: Implement your logic // - Remove trial access // - Send feedback survey // - Update trial conversion metrics await handleTrialCancelled({ appUserId, externalUserId, cancellationReason: event.cancellation_reason }); break; // ========================================== // BILLING EVENTS // ========================================== case 'BILLING_ISSUE': console.log(`Billing issue for ${appUserId}`); // TODO: Implement your logic // - Flag account with payment problem // - Send payment update reminder // - Start grace period // - Alert user via email/push await handleBillingIssue({ appUserId, externalUserId, activeEntitlements, gracePeriodExpiresAt: event.grace_period_expires_at_ms }); break; case 'EXPIRATION': console.log(`Subscription expired for ${appUserId}`); // TODO: Implement your logic // - Remove premium access // - Archive user data // - Send win-back campaign // - Update user to free tier await handleExpiration({ appUserId, externalUserId, expirationReason: event.expiration_reason, expirationDate: event.expiration_at_ms }); break; // ========================================== // REFUND EVENTS // ========================================== case 'REFUND': console.log(`Refund issued for ${appUserId}`); // TODO: Implement your logic // - Revoke access immediately // - Log refund for accounting // - Update analytics // - Flag potential abuse await handleRefund({ appUserId, externalUserId, productId: event.product_id, price: event.price, currency: event.currency }); break; case 'REFUND_REVERSED': console.log(`Refund reversed for ${appUserId}`); // TODO: Implement your logic // - Restore access // - Update accounting records // - Notify user await handleRefundReversed({ appUserId, externalUserId, activeEntitlements }); break; // ========================================== // SUBSCRIPTION CHANGES // ========================================== case 'PRODUCT_CHANGE': console.log(`Product changed for ${appUserId}`); // TODO: Implement your logic // - Update tier/plan // - Adjust feature access // - Update usage limits // - Log upgrade or downgrade await handleProductChange({ appUserId, externalUserId, activeEntitlements, newProductId: event.new_product_id, oldProductId: event.product_id, expirationDate: event.expiration_at_ms }); break; case 'SUBSCRIPTION_PAUSED': console.log(`Subscription paused for ${appUserId}`); // TODO: Implement your logic // - Temporarily suspend access // - Set auto-resume date // - Update UI to show paused state await handleSubscriptionPaused({ appUserId, externalUserId, autoResumeDate: event.auto_resume_at_ms }); break; case 'SUBSCRIPTION_EXTENDED': console.log(`Subscription extended for ${appUserId}`); // TODO: Implement your logic // - Update expiration date // - Log extension reason // - Send confirmation await handleSubscriptionExtended({ appUserId, externalUserId, newExpirationDate: event.expiration_at_ms, extensionReason: 'promotional' // or from event data }); break; // ========================================== // TRANSFER EVENTS // ========================================== case 'TRANSFER': console.log(`Subscription transferred for ${appUserId}`); // TODO: Implement your logic // - Move subscription to new user // - Update ownership records // - Sync across accounts await handleTransfer({ fromUserId: event.transferred_from?.[0], toUserId: appUserId, activeEntitlements }); break; // ========================================== // SPECIAL EVENTS // ========================================== case 'INVOICE_ISSUANCE': console.log(`Invoice issued for ${appUserId}`); // TODO: Implement your logic // - Store invoice data // - Send to accounting system // - Update billing records await handleInvoiceIssuance({ appUserId, externalUserId, invoiceData: event }); break; case 'TEMPORARY_ENTITLEMENT_GRANT': console.log(`Temporary entitlement granted for ${appUserId}`); // TODO: Implement your logic // - Grant temporary access (store issue) // - Set auto-expiry // - Log for monitoring await handleTemporaryEntitlement({ appUserId, externalUserId, activeEntitlements, expirationDate: event.expiration_at_ms }); break; case 'VIRTUAL_CURRENCY_TRANSACTION': console.log(`Virtual currency transaction for ${appUserId}`); // TODO: Implement your logic // - Update virtual currency balance // - Log transaction // - Sync wallet state await handleVirtualCurrency({ appUserId, externalUserId, amount: event.amount, currency: event.currency, transactionType: event.transaction_type }); break; case 'EXPERIMENT_ENROLLMENT': console.log(`Experiment enrollment for ${appUserId}`); // TODO: Implement your logic // - Assign user to variant // - Track enrollment // - Update analytics await handleExperimentEnrollment({ appUserId, externalUserId, experimentId: event.experiment_id, variantId: event.variant_id }); break; // ========================================== // UNKNOWN EVENTS (Future-proofing) // ========================================== default: console.warn(`Unknown event type: ${eventType}`); // TODO: Implement your logic // - Log for review // - Alert team // - Store for manual processing await handleUnknownEvent({ eventType, appUserId, externalUserId, event }); break; } } /** * Fetch complete subscriber data from RevenueCat API * Recommended after processing webhook for consistent data format */ async function fetchSubscriberData(appUserId) { try { const response = await fetch( `https://api.revenuecat.com/v1/subscribers/${appUserId}`, { headers: { 'Authorization': `Bearer ${REVENUECAT_API_KEY}`, 'Content-Type': 'application/json', 'X-Platform': 'stripe' // Optional: helps with analytics } } ); if (!response.ok) { throw new Error(`RevenueCat API error: ${response.status}`); } const data = await response.json(); return data.subscriber; } catch (error) { console.error('Error fetching subscriber data:', error); return null; } } /** * Fetch customer data from RevenueCat API V2 (recommended for real-time checks) * Use this as authoritative source of truth when validating subscription status */ async function fetchCustomerDataV2(customerId, projectId) { try { const response = await fetch( `https://api.revenuecat.com/v2/projects/${projectId}/customers/${customerId}`, { headers: { 'Authorization': `Bearer ${REVENUECAT_API_KEY}`, 'Content-Type': 'application/json' } } ); if (!response.ok) { if (response.status === 404) { console.warn(`Customer ${customerId} not found in RevenueCat`); return null; } throw new Error(`RevenueCat V2 API error: ${response.status}`); } const data = await response.json(); return data; } catch (error) { console.error('Error fetching customer data from V2 API:', error); return null; } } /** * Self-healing subscription status check * 1. Check database first (fast) * 2. If discrepancy detected, verify with RevenueCat V2 API * 3. Sync database if mismatch found */ async function verifyAndSyncSubscriptionStatus(appUserId, projectId) { try { // Step 1: Get current database status const dbStatus = await getSubscriptionStatusFromDB(appUserId); // Step 2: Get authoritative status from RevenueCat V2 const rcData = await fetchCustomerDataV2(appUserId, projectId); if (!rcData) { console.warn(`Customer ${appUserId} not found in RevenueCat`); return dbStatus; // Trust database if customer doesn't exist } // Step 3: Check for active entitlements // V2 API returns expires_at as milliseconds timestamp const hasActiveEntitlements = rcData.active_entitlements?.items?.length > 0; const rcIsActive = hasActiveEntitlements && rcData.active_entitlements.items.some(ent => { return ent.expires_at > Date.now(); }); // Step 4: Detect discrepancy if (dbStatus?.is_active !== rcIsActive) { console.warn(`Subscription status mismatch for ${appUserId}:`, { database: dbStatus?.is_active ? 'active' : 'inactive', revenuecat: rcIsActive ? 'active' : 'inactive' }); // Step 5: Sync database with RevenueCat (source of truth) await syncDatabaseWithRevenueCat(appUserId, rcData); console.log(`Synced ${appUserId} subscription status from RevenueCat`); } // Extract customer email and display name from attributes const email = rcData.attributes?.items?.find(attr => attr.name === '$email')?.value || null; const displayName = rcData.attributes?.items?.find(attr => attr.name === '$displayName')?.value || null; return { appUserId, isActive: rcIsActive, entitlements: rcData.active_entitlements?.items || [], email, displayName, lastSeen: rcData.last_seen_at, experiment: rcData.experiment, syncedAt: new Date() }; } catch (error) { console.error('Error in verifyAndSyncSubscriptionStatus:', error); // Fallback to database status on error const dbStatus = await getSubscriptionStatusFromDB(appUserId); return dbStatus; } } /** * Get subscription status from database */ async function getSubscriptionStatusFromDB(appUserId) { // TODO: Implement with your database // Example: // const result = await db.query( // 'SELECT * FROM user_subscriptions WHERE app_user_id = $1', // [appUserId] // ); // return result.rows[0]; return null; } /** * Sync database with RevenueCat data */ async function syncDatabaseWithRevenueCat(appUserId, rcData) { // TODO: Implement with your database // Update user_subscriptions table with V2 API data const hasActiveEntitlements = rcData.active_entitlements?.items?.length > 0; // Extract email and display name from attributes const email = rcData.attributes?.items?.find(attr => attr.name === '$email')?.value || null; const displayName = rcData.attributes?.items?.find(attr => attr.name === '$displayName')?.value || null; // Get latest expiration from active entitlements const latestExpiration = hasActiveEntitlements ? Math.max(...rcData.active_entitlements.items.map(ent => ent.expires_at)) : null; // Convert entitlements to database format const entitlementsForDB = {}; if (hasActiveEntitlements) { rcData.active_entitlements.items.forEach(ent => { entitlementsForDB[ent.entitlement_id] = { entitlement_id: ent.entitlement_id, expires_at: ent.expires_at }; }); } // Example database sync: // await db.query(` // UPDATE user_subscriptions // SET // is_active = $1, // entitlements = $2, // expires_at = $3, // email = $4, // external_user_id = $5, // last_seen_at = $6, // last_revenuecat_sync_at = NOW() // WHERE app_user_id = $7 // `, [ // hasActiveEntitlements, // JSON.stringify(entitlementsForDB), // latestExpiration ? new Date(latestExpiration) : null, // email, // displayName || email, // rcData.last_seen_at ? new Date(rcData.last_seen_at) : null, // appUserId // ]); console.log(`Database synced for ${appUserId}`, { hasActive: hasActiveEntitlements, entitlementCount: Object.keys(entitlementsForDB).length, email, latestExpiration: latestExpiration ? new Date(latestExpiration).toISOString() : null }); } /** * Extract complete entitlement data from webhook event * Based on actual RevenueCat webhook structure */ function extractEntitlementData(event) { return { // Entitlements (the key field!) entitlements: event.entitlements || {}, // User identification appUserId: event.app_user_id, originalAppUserId: event.original_app_user_id, aliases: event.aliases || [], // Subscriber attributes (email, displayName, custom attributes) subscriberAttributes: event.subscriber_attributes || {}, // Product information productId: event.product_id || event.new_product_id, oldProductId: event.product_id, // For product changes newProductId: event.new_product_id, // For product changes // Subscription dates purchasedAtMs: event.purchased_at_ms, expirationAtMs: event.expiration_at_ms, originalPurchaseDateMs: event.original_purchase_date_ms, // Grace period (billing issues) gracePeriodExpiresAtMs: event.grace_period_expires_at_ms, // Trial information isTrialConversion: event.is_trial_conversion, // Cancellation cancellationReason: event.cancellation_reason, autoResumeAtMs: event.auto_resume_at_ms, // Expiration reason expirationReason: event.expiration_reason, // Transfer information transferredFrom: event.transferred_from, transferredTo: event.transferred_to, // Metadata store: event.store, environment: event.environment, periodType: event.period_type, // Pricing price: event.price, priceInPurchasedCurrency: event.price_in_purchased_currency, currency: event.currency, // Event tracking eventId: event.id, eventType: event.type, eventTimestampMs: event.event_timestamp_ms }; } /** * Extract entitlements from webhook event * Handles the nested structure from RevenueCat webhooks * * IMPORTANT: Entitlements structure from RevenueCat: * { * "entitlement_id": { * "expires_date": "2026-01-17T12:00:00Z", * "grace_period_expires_date": null, * "product_identifier": "yearly_pro", * "purchase_date": "2025-01-17T12:00:00Z" * } * } */ function extractEntitlementsFromEvent(event) { // Entitlements are nested under event.entitlements // Structure: { entitlement_id: { expires_date, product_identifier, ... } } const entitlements = event.entitlements || {}; // Convert to array format with expiration checking const activeEntitlements = {}; Object.keys(entitlements).forEach(entitlementId => { const entitlement = entitlements[entitlementId]; // Check if entitlement is active const expiresDate = entitlement.expires_date; const isActive = !expiresDate || new Date(expiresDate) > new Date(); if (isActive) { activeEntitlements[entitlementId] = { entitlement_id: entitlementId, product_id: entitlement.product_identifier, expires_at: expiresDate, purchase_date: entitlement.purchase_date, grace_period_expires_at: entitlement.grace_period_expires_date }; } }); return activeEntitlements; } /** * Sync entitlements to database * Updates user_subscriptions table with current entitlements from webhook * * CRITICAL: This function MUST be called for EVERY webhook event to keep * the database in sync with RevenueCat's source of truth */ async function syncEntitlementsToDatabase(appUserId, entitlementData, event) { try { // Extract entitlements const entitlements = entitlementData.entitlements || event.entitlements || {}; const activeEntitlements = extractEntitlementsFromEvent({ entitlements }); const hasActiveEntitlements = Object.keys(activeEntitlements).length > 0; // Get subscriber attributes const subscriberAttributes = entitlementData.subscriberAttributes || event.subscriber_attributes || {}; const email = subscriberAttributes.$email?.value || null; const displayName = subscriberAttributes.$displayName?.value || null; // Product information const productId = entitlementData.productId || event.product_id || event.new_product_id; // Dates const expiresAt = entitlementData.expirationAtMs ? new Date(entitlementData.expirationAtMs) : (event.expiration_at_ms ? new Date(event.expiration_at_ms) : null); const purchasedAt = entitlementData.purchasedAtMs ? new Date(entitlementData.purchasedAtMs) : (event.purchased_at_ms ? new Date(event.purchased_at_ms) : null); const gracePeriodExpiresAt = entitlementData.gracePeriodExpiresAtMs ? new Date(entitlementData.gracePeriodExpiresAtMs) : (event.grace_period_expires_at_ms ? new Date(event.grace_period_expires_at_ms) : null); // Determine subscription status based on event type let subscriptionStatus = 'expired'; let autoRenew = true; let billingIssue = false; let cancelledAt = null; if (hasActiveEntitlements) { if (event.type === 'TRIAL_STARTED') { subscriptionStatus = 'trial'; } else if (event.type === 'SUBSCRIPTION_PAUSED') { subscriptionStatus = 'paused'; autoRenew = false; } else if (event.type === 'CANCELLATION') { subscriptionStatus = 'cancelled'; autoRenew = false; cancelledAt = new Date(event.event_timestamp_ms || Date.now()); } else if (event.type === 'BILLING_ISSUE') { subscriptionStatus = 'billing_issue'; billingIssue = true; } else { subscriptionStatus = 'active'; } } else { // No active entitlements subscriptionStatus = 'expired'; autoRenew = false; } // Store information const store = entitlementData.store || event.store; const environment = entitlementData.environment || event.environment; console.log(`Syncing entitlements for ${appUserId}:`, { hasActive: hasActiveEntitlements, status: subscriptionStatus, entitlementCount: Object.keys(activeEntitlements).length, entitlements: Object.keys(activeEntitlements), productId, expiresAt, email }); // TODO: Implement database update // This is the CRITICAL sync point - update your database here /* await db.query(` INSERT INTO user_subscriptions ( app_user_id, external_user_id, email, is_active, subscription_status, product_id, entitlements, expires_at, purchased_at, cancelled_at, grace_period_expires_at, auto_renew, billing_issue, store, environment, last_webhook_event_id, last_webhook_event_type, last_updated ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, NOW() ) ON CONFLICT (app_user_id) DO UPDATE SET external_user_id = COALESCE(EXCLUDED.external_user_id, user_subscriptions.external_user_id), email = COALESCE(EXCLUDED.email, user_subscriptions.email), is_active = EXCLUDED.is_active, subscription_status = EXCLUDED.subscription_status, product_id = EXCLUDED.product_id, entitlements = EXCLUDED.entitlements, expires_at = EXCLUDED.expires_at, purchased_at = COALESCE(EXCLUDED.purchased_at, user_subscriptions.purchased_at), cancelled_at = EXCLUDED.cancelled_at, grace_period_expires_at = EXCLUDED.grace_period_expires_at, auto_renew = EXCLUDED.auto_renew, billing_issue = EXCLUDED.billing_issue, store = EXCLUDED.store, environment = EXCLUDED.environment, last_webhook_event_id = EXCLUDED.last_webhook_event_id, last_webhook_event_type = EXCLUDED.last_webhook_event_type, last_updated = NOW() `, [ appUserId, displayName || email, email, hasActiveEntitlements, subscriptionStatus, productId, JSON.stringify(activeEntitlements), expiresAt, purchasedAt, cancelledAt, gracePeriodExpiresAt, autoRenew, billingIssue, store, environment, event.id, event.type ]); */ // Also sync to normalized entitlements table await syncEntitlementsToNormalizedTable(appUserId, activeEntitlements, event); } catch (error) { console.error('Error syncing entitlements to database:', error); throw error; } } /** * Check for duplicate event (idempotency) */ async function checkDuplicateEvent(eventId) { // TODO: Implement with your database // Check if eventId exists in processed_events table // Return true if exists, false otherwise // Example: // const exists = await db.query( // 'SELECT 1 FROM processed_webhook_events WHERE event_id = $1', // [eventId] // ); // return exists.rows.length > 0; return false; } /** * Mark event as processed */ async function markEventAsProcessed(eventId) { // TODO: Implement with your database // Insert eventId into processed_events table // Example: // await db.query( // 'INSERT INTO processed_webhook_events (event_id, processed_at) VALUES ($1, NOW())', // [eventId] // ); } // ========================================== // EVENT HANDLER TEMPLATES // Implement these functions with your business logic // ========================================== async function handleInitialPurchase(data) { // TODO: Implement // - Update user subscription status in database // - Grant access to premium features // - Send welcome email // - Track conversion event in analytics console.log('Initial purchase handler - implement your logic here', data); } async function handleRenewal(data) { // TODO: Implement // - Update subscription end date // - Log successful payment // - Update customer LTV console.log('Renewal handler - implement your logic here', data); } async function handleNonRenewingPurchase(data) { // TODO: Implement console.log('Non-renewing purchase handler - implement your logic here', data); } async function handleCancellation(data) { // TODO: Implement // - Update subscription status to "cancelled" // - Schedule access removal for expiration date // - Send feedback survey // - Track churn reason console.log('Cancellation handler - implement your logic here', data); } async function handleUncancellation(data) { // TODO: Implement console.log('Uncancellation handler - implement your logic here', data); } async function handleTrialStarted(data) { // TODO: Implement console.log('Trial started handler - implement your logic here', data); } async function handleTrialCancelled(data) { // TODO: Implement console.log('Trial cancelled handler - implement your logic here', data); } async function handleBillingIssue(data) { // TODO: Implement // - Flag account // - Send payment reminder email // - Start grace period console.log('Billing issue handler - implement your logic here', data); } async function handleExpiration(data) { // TODO: Implement // - Remove premium access // - Downgrade to free tier // - Send win-back email console.log('Expiration handler - implement your logic here', data); } async function handleRefund(data) { // TODO: Implement // - Immediately revoke access // - Log refund // - Update accounting console.log('Refund handler - implement your logic here', data); } async function handleRefundReversed(data) { // TODO: Implement console.log('Refund reversed handler - implement your logic here', data); } async function handleProductChange(data) { // TODO: Implement // - Update subscription tier // - Adjust feature access // - Log upgrade/downgrade console.log('Product change handler - implement your logic here', data); } async function handleSubscriptionPaused(data) { // TODO: Implement console.log('Subscription paused handler - implement your logic here', data); } async function handleSubscriptionExtended(data) { // TODO: Implement console.log('Subscription extended handler - implement your logic here', data); } async function handleTransfer(data) { // TODO: Implement console.log('Transfer handler - implement your logic here', data); } async function handleInvoiceIssuance(data) { // TODO: Implement console.log('Invoice issuance handler - implement your logic here', data); } async function handleTemporaryEntitlement(data) { // TODO: Implement console.log('Temporary entitlement handler - implement your logic here', data); } async function handleVirtualCurrency(data) { // TODO: Implement console.log('Virtual currency handler - implement your logic here', data); } async function handleExperimentEnrollment(data) { // TODO: Implement console.log('Experiment enrollment handler - implement your logic here', data); } async function handleUnknownEvent(data) { // TODO: Implement // - Log event for manual review // - Alert development team // - Store in database for later processing console.log('Unknown event handler - implement your logic here', data); } // ========================================== // HELPER FUNCTIONS // ========================================== /** * Check if user has active entitlement (V2 API format) * V2 API returns expires_at as milliseconds timestamp */ function hasActiveEntitlementV2(customerData, entitlementId = null) { if (!customerData?.active_entitlements?.items) { return false; } return customerData.active_entitlements.items.some(ent => { // If entitlementId specified, check for specific entitlement if (entitlementId && ent.entitlement_id !== entitlementId) { return false; } // Check if entitlement is still valid // expires_at is in milliseconds timestamp return ent.expires_at > Date.now(); }); } /** * Determine if we should verify status with RevenueCat */ function shouldVerifyWithRevenueCat(cachedStatus) { if (!cachedStatus) { return true; // No cached data, must check } // Always verify if subscription expires soon (within 24 hours) if (cachedStatus.expires_at) { const expiresAt = new Date(cachedStatus.expires_at); const hoursUntilExpiry = (expiresAt - new Date()) / (1000 * 60 * 60); if (hoursUntilExpiry < 24) { return true; // Verify near expiration } } // Verify if billing issue detected if (cachedStatus.billing_issue) { return true; } // Verify if recently cancelled (within 48 hours) if (cachedStatus.cancelled_at) { const cancelledAt = new Date(cachedStatus.cancelled_at); const hoursSinceCancellation = (new Date() - cancelledAt) / (1000 * 60 * 60); if (hoursSinceCancellation < 48) { return true; // Verify recent cancellations } } // Verify if cache is stale (older than 1 hour) if (cachedStatus.last_updated) { const lastUpdated = new Date(cachedStatus.last_updated); const hoursSinceUpdate = (new Date() - lastUpdated) / (1000 * 60 * 60); if (hoursSinceUpdate > 1) { return true; // Cache is stale } } return false; // Use cached data } /** * Check if user has active entitlement */ function hasActiveEntitlement(activeEntitlements, entitlementId) { return activeEntitlements[entitlementId]?.expires_date_ms ? activeEntitlements[entitlementId].expires_date_ms > Date.now() : false; } /** * Get all active entitlement IDs */ function getActiveEntitlementIds(activeEntitlements) { return Object.keys(activeEntitlements).filter(entitlementId => hasActiveEntitlement(activeEntitlements, entitlementId) ); } /** * Get all active entitlement IDs from V2 API customer data */ function getActiveEntitlementIdsV2(customerData) { if (!customerData?.active_entitlements?.items) { return []; } return customerData.active_entitlements.items .filter(ent => ent.expires_at > Date.now()) .map(ent => ent.entitlement_id); } /** * Get customer email from V2 API attributes */ function getCustomerEmailV2(customerData) { if (!customerData?.attributes?.items) { return null; } const emailAttr = customerData.attributes.items.find( attr => attr.name === '$email' ); return emailAttr?.value || null; } /** * Get customer display name from V2 API attributes */ function getCustomerDisplayNameV2(customerData) { if (!customerData?.attributes?.items) { return null; } const nameAttr = customerData.attributes.items.find( attr => attr.name === '$displayName' ); return nameAttr?.value || null; } /** * Get custom attribute from V2 API customer data */ function getCustomerAttributeV2(customerData, attributeName) { if (!customerData?.attributes?.items) { return null; } const attr = customerData.attributes.items.find( a => a.name === attributeName ); return attr?.value || null; } /** * Extract user email from subscriber attributes */ function getUserEmail(subscriberAttributes) { return subscriberAttributes.$email?.value || null; } /** * Extract custom attributes */ function getCustomAttribute(subscriberAttributes, key) { return subscriberAttributes[key]?.value || null; } // ========================================== // EXPRESS.JS EXAMPLE // ========================================== // const express = require('express'); // const app = express(); // // app.use(express.json()); // // app.post('/webhooks/revenuecat', handleRevenueCatWebhook); // // app.listen(3000, () => { // console.log('Webhook server running on port 3000'); // }); // ========================================== // NEXT.JS API ROUTE EXAMPLE // ========================================== // // pages/api/webhooks/revenuecat.js // export default async function handler(req, res) { // if (req.method !== 'POST') { // return res.status(405).json({ error: 'Method not allowed' }); // } // // return handleRevenueCatWebhook(req, res); // } module.exports = { handleRevenueCatWebhook, validateAuthorization, // V1 API fetchSubscriberData, // V2 API fetchCustomerDataV2, verifyAndSyncSubscriptionStatus, getSubscriptionStatusFromDB, syncDatabaseWithRevenueCat, hasActiveEntitlementV2, getActiveEntitlementIdsV2, getCustomerEmailV2, getCustomerDisplayNameV2, getCustomerAttributeV2, shouldVerifyWithRevenueCat, // Entitlement Management extractEntitlementData, extractEntitlementsFromEvent, syncEntitlementsToDatabase, syncEntitlementsToNormalizedTable, getUserEntitlements, userHasEntitlement, // Helper functions (V1/Webhook format) hasActiveEntitlement, getActiveEntitlementIds, getUserEmail, getCustomAttribute }; ``` *** ## Quick Start ### 1. Set Up Webhook in RevenueCat Dashboard 1. Navigate to **Integrations** → **Webhooks** 2. Click **Add new configuration** 3. Set Authorization Header: `Bearer MYSECRET` 4. Enter your webhook URL: `https://yourdomain.com/webhooks/revenuecat` 5. Select environment (Production, Sandbox, or Both) ### 2. Configure Your Server ```dotenv theme={null} // .env file REVENUECAT_WEBHOOK_SECRET=MYSECRET REVENUECAT_API_KEY=your_api_key_here ``` ### 3. Deploy Webhook Endpoint Deploy the handler to your server and ensure it responds within 60 seconds. ### 4. Test with RevenueCat Dashboard Use the "Send Test Event" button in RevenueCat dashboard to verify your implementation. *** ## Database Schema Example ```sql theme={null} -- Track processed events (idempotency) CREATE TABLE processed_webhook_events ( event_id VARCHAR(255) PRIMARY KEY, event_type VARCHAR(100) NOT NULL, app_user_id VARCHAR(255) NOT NULL, environment VARCHAR(20), -- PRODUCTION or SANDBOX processed_at TIMESTAMP DEFAULT NOW(), created_at TIMESTAMP DEFAULT NOW() ); CREATE INDEX idx_app_user_id ON processed_webhook_events(app_user_id); CREATE INDEX idx_processed_at ON processed_webhook_events(processed_at); CREATE INDEX idx_event_type ON processed_webhook_events(event_type); -- User subscriptions table CREATE TABLE user_subscriptions ( id SERIAL PRIMARY KEY, app_user_id VARCHAR(255) UNIQUE NOT NULL, external_user_id VARCHAR(255), email VARCHAR(255), -- Subscription status is_active BOOLEAN DEFAULT false, subscription_status VARCHAR(50), -- active, cancelled, expired, trial, paused product_id VARCHAR(255), entitlements JSONB, -- Store active entitlements as JSON -- Dates expires_at TIMESTAMP, purchased_at TIMESTAMP, cancelled_at TIMESTAMP, trial_end_at TIMESTAMP, -- Flags auto_renew BOOLEAN DEFAULT true, billing_issue BOOLEAN DEFAULT false, -- Metadata store VARCHAR(50), -- app_store, play_store, stripe, etc environment VARCHAR(20), -- PRODUCTION or SANDBOX -- Tracking last_webhook_event_id VARCHAR(255), last_webhook_event_type VARCHAR(100), last_revenuecat_sync_at TIMESTAMP, last_updated TIMESTAMP DEFAULT NOW(), created_at TIMESTAMP DEFAULT NOW() ); CREATE INDEX idx_external_user_id ON user_subscriptions(external_user_id); CREATE INDEX idx_email ON user_subscriptions(email); CREATE INDEX idx_subscription_status ON user_subscriptions(subscription_status); CREATE INDEX idx_is_active ON user_subscriptions(is_active); CREATE INDEX idx_expires_at ON user_subscriptions(expires_at); -- Dedicated entitlements table (normalized approach) CREATE TABLE user_entitlements ( id SERIAL PRIMARY KEY, app_user_id VARCHAR(255) NOT NULL, entitlement_id VARCHAR(255) NOT NULL, product_id VARCHAR(255) NOT NULL, -- Status is_active BOOLEAN DEFAULT true, -- Dates granted_at TIMESTAMP DEFAULT NOW(), expires_at TIMESTAMP, grace_period_expires_at TIMESTAMP, purchase_date TIMESTAMP, -- Metadata store VARCHAR(50), environment VARCHAR(20), -- Tracking created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW(), UNIQUE(app_user_id, entitlement_id) ); CREATE INDEX idx_entitlement_user ON user_entitlements(app_user_id); CREATE INDEX idx_entitlement_id ON user_entitlements(entitlement_id); CREATE INDEX idx_entitlement_active ON user_entitlements(is_active); CREATE INDEX idx_entitlement_expires ON user_entitlements(expires_at); -- Track RevenueCat API calls for monitoring CREATE TABLE revenuecat_api_calls ( id SERIAL PRIMARY KEY, endpoint VARCHAR(255) NOT NULL, -- v1/subscribers or v2/customers app_user_id VARCHAR(255), status_code INTEGER, response_time_ms INTEGER, error_message TEXT, called_at TIMESTAMP DEFAULT NOW() ); CREATE INDEX idx_api_endpoint ON revenuecat_api_calls(endpoint); CREATE INDEX idx_api_user ON revenuecat_api_calls(app_user_id); CREATE INDEX idx_api_called_at ON revenuecat_api_calls(called_at); -- Webhook delivery failures (for retry logic) CREATE TABLE webhook_failures ( id SERIAL PRIMARY KEY, event_id VARCHAR(255) NOT NULL, event_type VARCHAR(100) NOT NULL, app_user_id VARCHAR(255) NOT NULL, error_message TEXT, retry_count INTEGER DEFAULT 0, next_retry_at TIMESTAMP, created_at TIMESTAMP DEFAULT NOW(), resolved_at TIMESTAMP ); CREATE INDEX idx_webhook_failures_user ON webhook_failures(app_user_id); CREATE INDEX idx_webhook_failures_retry ON webhook_failures(next_retry_at); ``` ### Database Sync Functions ```javascript theme={null} /** * Sync entitlements to normalized table * Recommended for complex entitlement logic */ async function syncEntitlementsToNormalizedTable(appUserId, activeEntitlements, event) { // Get all entitlement IDs from event const entitlementIds = Object.keys(activeEntitlements); // Mark all existing entitlements as inactive first await db.query(` UPDATE user_entitlements SET is_active = false, updated_at = NOW() WHERE app_user_id = $1 `, [appUserId]); // Insert or update each active entitlement for (const entitlementId of entitlementIds) { const ent = activeEntitlements[entitlementId]; await db.query(` INSERT INTO user_entitlements ( app_user_id, entitlement_id, product_id, is_active, expires_at, grace_period_expires_at, purchase_date, store, environment, updated_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW()) ON CONFLICT (app_user_id, entitlement_id) DO UPDATE SET product_id = EXCLUDED.product_id, is_active = EXCLUDED.is_active, expires_at = EXCLUDED.expires_at, grace_period_expires_at = EXCLUDED.grace_period_expires_at, purchase_date = EXCLUDED.purchase_date, store = EXCLUDED.store, environment = EXCLUDED.environment, updated_at = NOW() `, [ appUserId, entitlementId, ent.product_id, true, ent.expires_at ? new Date(ent.expires_at) : null, ent.grace_period_expires_at ? new Date(ent.grace_period_expires_at) : null, ent.purchase_date ? new Date(ent.purchase_date) : null, event.store, event.environment ]); } } /** * Get user's active entitlements from database */ async function getUserEntitlements(appUserId) { const result = await db.query(` SELECT entitlement_id, product_id, expires_at, grace_period_expires_at, purchase_date, is_active FROM user_entitlements WHERE app_user_id = $1 AND is_active = true AND (expires_at IS NULL OR expires_at > NOW()) ORDER BY purchase_date DESC `, [appUserId]); // Convert to object format const entitlements = {}; result.rows.forEach(row => { entitlements[row.entitlement_id] = { product_id: row.product_id, expires_at: row.expires_at, grace_period_expires_at: row.grace_period_expires_at, purchase_date: row.purchase_date }; }); return entitlements; } /** * Check if user has specific entitlement */ async function userHasEntitlement(appUserId, entitlementId) { const result = await db.query(` SELECT 1 FROM user_entitlements WHERE app_user_id = $1 AND entitlement_id = $2 AND is_active = true AND (expires_at IS NULL OR expires_at > NOW()) LIMIT 1 `, [appUserId, entitlementId]); return result.rows.length > 0; } ``` *** ## Database Schema Example *** ## Environment Variables Create a `.env` file with the following configuration: ``` # RevenueCat Configuration REVENUECAT_API_KEY=your_secret_api_key_here REVENUECAT_PROJECT_ID=your_project_id_here REVENUECAT_WEBHOOK_SECRET=MYSECRET # Database Configuration DATABASE_URL=postgresql://user:password@localhost:5432/dbname # Application Configuration NODE_ENV=production PORT=3000 # Optional: Rate Limiting REVENUECAT_API_RATE_LIMIT=480 REVENUECAT_API_RATE_WINDOW=60000 # Optional: Caching CACHE_TTL_SECONDS=3600 ENABLE_REVENUECAT_CACHE=true # Optional: Monitoring ENABLE_API_CALL_TRACKING=true ENABLE_WEBHOOK_FAILURE_TRACKING=true ``` ### Security Notes **Never commit these values to version control:** ``` # .gitignore .env .env.local .env.*.local ``` **Use environment-specific files:** ``` .env.development .env.staging .env.production ``` **Validate environment variables on startup:** ```javascript theme={null} // config/env.js const requiredEnvVars = [ 'REVENUECAT_API_KEY', 'REVENUECAT_PROJECT_ID', 'REVENUECAT_WEBHOOK_SECRET', 'DATABASE_URL' ]; function validateEnvironment() { const missing = requiredEnvVars.filter(key => !process.env[key]); if (missing.length > 0) { throw new Error( `Missing required environment variables: ${missing.join(', ')}` ); } console.log('Environment variables validated'); } validateEnvironment(); ``` *** ## Database Schema Example *** ## Security Best Practices ### 1. Always Validate Authorization ```javascript theme={null} // Never skip authorization validation if (!validateAuthorization(req)) { return res.status(401).json({ error: 'Unauthorized' }); } ``` ### 2. Respond Quickly ```javascript theme={null} // Respond within 60 seconds // Defer heavy processing to background jobs await queueBackgroundJob('process-webhook', event); return res.status(200).json({ message: 'Accepted' }); ``` ### 3. Handle Duplicates ```javascript theme={null} // Implement idempotency using event.id const isDuplicate = await checkDuplicateEvent(event.id); if (isDuplicate) { return res.status(200).json({ message: 'Already processed' }); } ``` ### 4. Use HTTPS Only Configure your webhook URL with HTTPS. HTTP is not supported. ### 5. Verify Event Structure ```javascript theme={null} // Validate required fields if (!event || !event.type || !event.app_user_id) { return res.status(400).json({ error: 'Invalid event structure' }); } ``` *** ## Testing Guide ### Test with RevenueCat Dashboard 1. Go to **Integrations** → **Webhooks** 2. Click on your webhook configuration 3. Click **Send Test Event** 4. Select event type 5. Verify your endpoint receives and processes the event ### Test with Sandbox Purchases Make test purchases in sandbox environment: ```javascript theme={null} // The environment field will be "SANDBOX" if (event.environment === 'SANDBOX') { console.log('Test purchase - skip production workflows'); } ``` *** ## Common Patterns ### Sync Subscription Status ```javascript theme={null} async function syncSubscriptionStatus(appUserId, event) { const activeEntitlements = event.entitlements || {}; const hasActiveSubscription = Object.keys(activeEntitlements).length > 0; await db.query(` UPDATE user_subscriptions SET subscription_status = $1, entitlements = $2, expires_at = $3, last_updated = NOW() WHERE app_user_id = $4 `, [ hasActiveSubscription ? 'active' : 'expired', JSON.stringify(activeEntitlements), event.expiration_at_ms ? new Date(event.expiration_at_ms) : null, appUserId ]); } ``` ### Send Email Notification ```javascript theme={null} async function sendCancellationEmail(email, data) { // Use your email service await emailService.send({ to: email, template: 'subscription-cancelled', data: { userName: data.externalUserId, expirationDate: new Date(data.expirationDate).toLocaleDateString(), feedbackUrl: 'https://yourapp.com/feedback' } }); } ``` ### Update Analytics ```javascript theme={null} async function trackWebhookEvent(eventType, userId, data) { await analytics.track({ userId, event: `Subscription ${eventType}`, properties: { productId: data.productId, price: data.price, currency: data.currency, environment: data.environment } }); } ``` *** ## Troubleshooting ### Webhooks Not Received * Verify HTTPS endpoint is accessible * Check authorization header matches exactly * Ensure server responds within 60 seconds * Check RevenueCat dashboard for delivery errors ### Duplicate Events * Implement idempotency using `event.id` * Check database for already-processed events * Return 200 OK even for duplicates ### Missing Data * Use `GET /subscribers` API for complete data * Check `event.entitlements` for active subscriptions * Verify `event.subscriber_attributes` for custom data *** ## RevenueCat API: V1 vs V2 RevenueCat provides two API versions for fetching customer data. Understanding when to use each is critical for building a robust subscription system. ### API V1 - Subscribers Endpoint **Endpoint:** ```shellscript theme={null} GET https://api.revenuecat.com/v1/subscribers/{app_user_id} ``` **Use Cases:** * Fetching complete subscriber information after webhook * Getting subscriber attributes and custom metadata * Historical purchase data * Integration aliases (multiple IDs for same customer) **Example Request:** ```shellscript theme={null} curl -X GET "https://api.revenuecat.com/v1/subscribers/user_abc123" \ -H "Authorization: Bearer YOUR_REVENUECAT_API_KEY" \ -H "Content-Type: application/json" ``` **Example Response:** ```json theme={null} { "request_date": "2025-01-17T12:00:00Z", "request_date_ms": 1737115200000, "subscriber": { "original_app_user_id": "user_abc123", "original_application_version": "1.0", "first_seen": "2024-01-01T00:00:00Z", "last_seen": "2025-01-17T10:00:00Z", "management_url": "https://apps.apple.com/account/subscriptions", "original_purchase_date": "2024-01-15T12:00:00Z", "non_subscriptions": {}, "subscriptions": { "yearly_pro": { "auto_resume_date": null, "billing_issues_detected_at": null, "expires_date": "2025-02-15T12:00:00Z", "grace_period_expires_date": null, "is_sandbox": false, "original_purchase_date": "2024-02-15T12:00:00Z", "ownership_type": "PURCHASED", "period_type": "normal", "purchase_date": "2025-01-15T12:00:00Z", "refunded_at": null, "store": "app_store", "unsubscribe_detected_at": null } }, "entitlements": { "pro": { "expires_date": "2025-02-15T12:00:00Z", "grace_period_expires_date": null, "product_identifier": "yearly_pro", "purchase_date": "2025-01-15T12:00:00Z" } }, "subscriber_attributes": { "$email": { "value": "user@example.com", "updated_at_ms": 1737115200000 }, "$displayName": { "value": "John Doe", "updated_at_ms": 1737115200000 } } } } ``` **JavaScript Implementation:** ```javascript theme={null} async function getSubscriberV1(appUserId) { const response = await fetch( `https://api.revenuecat.com/v1/subscribers/${appUserId}`, { headers: { 'Authorization': `Bearer ${REVENUECAT_API_KEY}`, 'Content-Type': 'application/json' } } ); if (!response.ok) { throw new Error(`RevenueCat V1 API error: ${response.status}`); } const data = await response.json(); return data.subscriber; } ``` *** ### API V2 - Customers Endpoint (Recommended for Real-Time Checks) **Endpoint:** ```shellscript theme={null} GET https://api.revenuecat.com/v2/projects/{project_id}/customers/{customer_id} ``` **Use Cases:** * Real-time subscription status verification * Source of truth for active entitlements * Self-healing when webhooks fail or are delayed * Backup validation before granting access **Why V2 is Better for Status Checks:** * Returns active entitlements directly * Cleaner, more focused response structure * Explicitly shows what's currently active * Better for programmatic status checks * Includes customer attributes in paginated format * Provides experiment enrollment data * Tracks customer metadata (last\_seen, platform, country, etc.) **Important V2 API Differences:** * `expires_at` is returned as **milliseconds timestamp** (not ISO date string) * `active_entitlements.items` contains only `entitlement_id` and `expires_at` (no product\_id) * Customer attributes (email, displayName) are in separate `attributes.items` array * No `subscriptions` object - only active entitlements are returned * Responses are paginated (though most apps fit in single page) **Example Request:** ```shellscript theme={null} curl -X GET "https://api.revenuecat.com/v2/projects/abc123/customers/user_abc123" \ -H "Authorization: Bearer YOUR_REVENUECAT_API_KEY" \ -H "Content-Type: application/json" ``` **Example Response:** ```json theme={null} { "object": "customer", "id": "19b8de26-77c1-49f1-aa18-019a391603e2", "project_id": "proj1ab2c3d4", "first_seen_at": 1658399423658, "last_seen_at": 1658399423658, "last_seen_app_version": "1.0.0", "last_seen_country": "US", "last_seen_platform": "android", "last_seen_platform_version": "35", "active_entitlements": { "object": "list", "items": [ { "object": "customer.active_entitlement", "entitlement_id": "pro", "expires_at": 1768651200000 }, { "object": "customer.active_entitlement", "entitlement_id": "premium_features", "expires_at": 1768651200000 } ], "next_page": null, "url": "/v2/projects/proj1ab2c3d4/customers/19b8de26-77c1-49f1-aa18-019a391603e2/active_entitlements" }, "experiment": { "object": "experiment_enrollment", "id": "exp_123", "name": "pricing_test_v2", "variant": "a" }, "attributes": { "object": "list", "items": [ { "object": "customer.attribute", "name": "$email", "value": "user@example.com", "updated_at": 1658399423658 }, { "object": "customer.attribute", "name": "$displayName", "value": "John Doe", "updated_at": 1658399423658 } ], "next_page": null, "url": "/v2/projects/proj1ab2c3d4/customers/19b8de26-77c1-49f1-aa18-019a391603e2/attributes" } } ``` **JavaScript Implementation:** ```javascript theme={null} async function getCustomerV2(customerId, projectId) { const response = await fetch( `https://api.revenuecat.com/v2/projects/${projectId}/customers/${customerId}`, { headers: { 'Authorization': `Bearer ${REVENUECAT_API_KEY}`, 'Content-Type': 'application/json' } } ); if (!response.ok) { if (response.status === 404) { return null; // Customer not found } throw new Error(`RevenueCat V2 API error: ${response.status}`); } return await response.json(); } // Check if customer has active entitlement function hasActiveEntitlementV2(customerData, entitlementId) { if (!customerData?.active_entitlements?.items) { return false; } return customerData.active_entitlements.items.some(ent => { if (entitlementId && ent.entitlement_id !== entitlementId) { return false; } const expiresAt = new Date(ent.expires_at); return expiresAt > new Date(); }); } ``` *** ### Comparison: V1 vs V2 | Feature | V1 (/subscribers) | V2 (/customers) | | :----------------------- | :----------------------------------- | :----------------------------------------- | | **Response Structure** | Detailed, nested | Clean, paginated | | **Active Entitlements** | In `entitlements` object | Explicit `active_entitlements.items` array | | **Timestamp Format** | ISO date strings | Milliseconds (numbers) | | **Customer Attributes** | `subscriber_attributes` object | `attributes.items` array (paginated) | | **Product Information** | Includes product\_id in entitlements | Only entitlement\_id + expires\_at | | **Subscription Details** | Full subscription history | Not included | | **Experiment Data** | Not included | Included in `experiment` object | | **Customer Metadata** | Limited | Rich (last\_seen, platform, country, etc.) | | **Primary Use Case** | Full customer history | Real-time status checks | | **Best For** | After webhook processing | Self-healing validation | | **Rate Limit** | 480 requests/minute | 480 requests/minute | | **Project ID Required** | No | Yes | | **Response Size** | Larger (more metadata) | Smaller (focused data) | *** ### Working with V2 API Responses **Extract active entitlements:** ```javascript theme={null} const rcData = await fetchCustomerDataV2(userId, projectId); // Check if user has any active access const hasAccess = rcData?.active_entitlements?.items?.length > 0 && rcData.active_entitlements.items.some(ent => ent.expires_at > Date.now()); // Get all active entitlement IDs const entitlementIds = rcData?.active_entitlements?.items ?.filter(ent => ent.expires_at > Date.now()) .map(ent => ent.entitlement_id) || []; // Result: ["pro", "premium_features"] // Check specific entitlement const hasPro = rcData?.active_entitlements?.items?.some(ent => ent.entitlement_id === 'pro' && ent.expires_at > Date.now() ); ``` **Extract customer attributes:** ```javascript theme={null} const rcData = await fetchCustomerDataV2(userId, projectId); // Get email const email = rcData?.attributes?.items ?.find(attr => attr.name === '$email') ?.value; // Get display name const displayName = rcData?.attributes?.items ?.find(attr => attr.name === '$displayName') ?.value; // Get custom attribute const customValue = rcData?.attributes?.items ?.find(attr => attr.name === 'my_custom_attribute') ?.value; ``` **Get customer metadata:** ```javascript theme={null} const rcData = await fetchCustomerDataV2(userId, projectId); const metadata = { firstSeen: new Date(rcData.first_seen_at), lastSeen: new Date(rcData.last_seen_at), appVersion: rcData.last_seen_app_version, country: rcData.last_seen_country, platform: rcData.last_seen_platform, // "ios", "android", etc. platformVersion: rcData.last_seen_platform_version }; ``` **Get experiment enrollment:** ```javascript theme={null} const rcData = await fetchCustomerDataV2(userId, projectId); if (rcData.experiment) { console.log('User in experiment:', rcData.experiment.name); console.log('Variant:', rcData.experiment.variant); // "a", "b", "control", etc. } ``` **Complete example:** ```javascript theme={null} async function checkUserAccess(userId, projectId) { const rcData = await fetchCustomerDataV2(userId, projectId); if (!rcData) { return { hasAccess: false, error: 'Customer not found' }; } // Get active entitlements const activeEntitlements = rcData.active_entitlements?.items ?.filter(ent => ent.expires_at > Date.now()) || []; // Get customer info const email = rcData.attributes?.items ?.find(attr => attr.name === '$email')?.value; return { hasAccess: activeEntitlements.length > 0, entitlements: activeEntitlements.map(ent => ent.entitlement_id), email, lastSeen: new Date(rcData.last_seen_at), experiment: rcData.experiment }; } ``` *** ### Self-Healing Pattern Use this pattern to ensure your database stays synchronized with RevenueCat, even if webhooks are missed or delayed. #### The Problem Webhooks can fail due to: * Network issues during delivery * Server downtime when webhook arrives * Processing errors in your handler * Race conditions with concurrent events * Webhook delivery delays (2+ hours for cancellations) #### The Solution: Database + API V2 Backup ```mermaid theme={null} flowchart TD Start([User Request:
Check Subscription Status]) Start --> CheckDB[1. Check Database
subscription_status table
Fast - local query] CheckDB --> IsDefinitive{Is status
definitive?} IsDefinitive -->|YES| ReturnCached[Return cached
status] IsDefinitive -->|NO
expired soon,
billing issue, etc| CallAPI[2. Call RevenueCat V2
GET /v2/customers/id
Authoritative] CallAPI --> Compare[3. Compare results] Compare --> HasDiscrepancy{Discrepancy
detected?} HasDiscrepancy -->|NO| ReturnRC[Return RC
status] HasDiscrepancy -->|YES| SyncDB[4. Sync Database
with RevenueCat
source of truth] SyncDB --> ReturnSynced[5. Return synced
status to user] ReturnCached --> End([End]) ReturnRC --> End ReturnSynced --> End style Start fill:#e1f5e1 style End fill:#e1e1e1 style CheckDB fill:#e1f0ff style CallAPI fill:#fff4e1 style IsDefinitive fill:#ffe1e1 style HasDiscrepancy fill:#ffe1e1 style ReturnCached fill:#e1f5e1 style ReturnRC fill:#e1f5e1 style ReturnSynced fill:#e1f5e1 style Compare fill:#f0f0f0 style SyncDB fill:#fff4e1 ``` #### Implementation ```javascript theme={null} /** * Get subscription status with self-healing * Uses database for speed, RevenueCat V2 API for accuracy */ async function getSubscriptionStatus(userId, projectId) { // Step 1: Check database first (fast) const dbStatus = await db.query( 'SELECT * FROM user_subscriptions WHERE app_user_id = $1', [userId] ); const cachedStatus = dbStatus.rows[0]; // Step 2: Determine if we need to verify with RevenueCat const needsVerification = shouldVerifyWithRevenueCat(cachedStatus); if (!needsVerification) { return { isActive: cachedStatus?.is_active || false, entitlements: cachedStatus?.entitlements || {}, source: 'database' }; } // Step 3: Fetch from RevenueCat V2 (authoritative) const rcData = await getCustomerV2(userId, projectId); if (!rcData) { // Customer doesn't exist in RevenueCat return { isActive: false, entitlements: {}, source: 'revenuecat', error: 'Customer not found' }; } // Step 4: Check for active entitlements const hasActive = hasActiveEntitlementV2(rcData); // Step 5: Detect discrepancy and sync if needed if (cachedStatus && cachedStatus.is_active !== hasActive) { console.warn('Subscription status mismatch detected', { userId, database: cachedStatus.is_active, revenuecat: hasActive }); // Sync database with RevenueCat (source of truth) await syncSubscriptionStatus(userId, rcData); } return { isActive: hasActive, entitlements: rcData.active_entitlements?.items || [], subscriptions: rcData.subscriptions?.items || [], source: 'revenuecat', synced: true }; } /** * Determine if we should verify status with RevenueCat */ function shouldVerifyWithRevenueCat(cachedStatus) { if (!cachedStatus) { return true; // No cached data, must check } // Always verify if subscription expires soon (within 24 hours) if (cachedStatus.expires_at) { const expiresAt = new Date(cachedStatus.expires_at); const hoursUntilExpiry = (expiresAt - new Date()) / (1000 * 60 * 60); if (hoursUntilExpiry < 24) { return true; // Verify near expiration } } // Verify if billing issue detected if (cachedStatus.billing_issue) { return true; } // Verify if recently cancelled (within 48 hours) if (cachedStatus.cancelled_at) { const cancelledAt = new Date(cachedStatus.cancelled_at); const hoursSinceCancellation = (new Date() - cancelledAt) / (1000 * 60 * 60); if (hoursSinceCancellation < 48) { return true; // Verify recent cancellations } } // Verify if cache is stale (older than 1 hour) if (cachedStatus.last_updated) { const lastUpdated = new Date(cachedStatus.last_updated); const hoursSinceUpdate = (new Date() - lastUpdated) / (1000 * 60 * 60); if (hoursSinceUpdate > 1) { return true; // Cache is stale } } return false; // Use cached data } /** * Sync database with RevenueCat data */ async function syncSubscriptionStatus(userId, rcData) { const hasActive = hasActiveEntitlementV2(rcData); const latestSubscription = rcData.subscriptions?.items?.[0]; await db.query(` INSERT INTO user_subscriptions ( app_user_id, is_active, entitlements, expires_at, product_id, store, last_updated ) VALUES ($1, $2, $3, $4, $5, $6, NOW()) ON CONFLICT (app_user_id) DO UPDATE SET is_active = $2, entitlements = $3, expires_at = $4, product_id = $5, store = $6, last_updated = NOW() `, [ userId, hasActive, JSON.stringify(rcData.active_entitlements?.items || []), latestSubscription?.expires_at, latestSubscription?.product_id, latestSubscription?.store ]); console.log(`Synced subscription status for ${userId} from RevenueCat`); } ``` *** ### Rate Limiting Both V1 and V2 APIs share the same rate limit: **Limit:** 480 requests per minute per API key **Best Practices:** 1. **Cache aggressively** - Store results in database 2. **Use webhooks as primary** - API calls as backup only 3. **Implement exponential backoff** on rate limit errors 4. **Monitor API usage** - Set up alerts at 80% capacity ```javascript theme={null} // Rate limit error handling async function callRevenueCatAPI(url, options) { try { const response = await fetch(url, options); if (response.status === 429) { // Rate limited const retryAfter = response.headers.get('Retry-After') || 60; console.warn(`Rate limited. Retry after ${retryAfter}s`); // Wait and retry await new Promise(resolve => setTimeout(resolve, retryAfter * 1000)); return callRevenueCatAPI(url, options); } return response; } catch (error) { console.error('RevenueCat API error:', error); throw error; } } ``` *** ### When to Use Which API | Scenario | Use API V1 | Use API V2 | | :---------------------------- | :---------- | :---------- | | After processing webhook | ✅ Yes | ⚠️ Optional | | Real-time status check | ❌ No | ✅ Yes | | Self-healing validation | ❌ No | ✅ Yes | | Getting subscriber attributes | ✅ Yes | ❌ No | | Checking active entitlements | ⚠️ Possible | ✅ Better | | Historical purchase data | ✅ Yes | ⚠️ Limited | | Building customer profile | ✅ Yes | ❌ No | **General Rule:** Use V2 for status checks, V1 for detailed customer information. *** ## API Endpoint Examples ### Subscription Status Endpoint Create an endpoint that clients can call to check subscription status: ```javascript theme={null} // GET /api/subscription-status async function handleSubscriptionStatus(req, res) { try { const userId = req.user.id; // From authentication middleware const projectId = process.env.REVENUECAT_PROJECT_ID; // Use self-healing pattern const status = await verifyAndSyncSubscriptionStatus(userId, projectId); return res.json({ isActive: status.isActive, entitlements: status.entitlements, subscriptions: status.subscriptions, syncedAt: status.syncedAt }); } catch (error) { console.error('Error checking subscription status:', error); return res.status(500).json({ error: 'Failed to check subscription status' }); } } ``` ### Force Sync Endpoint Create an endpoint to manually trigger sync with RevenueCat: ```javascript theme={null} // POST /api/sync-revenuecat async function handleForceSync(req, res) { try { const userId = req.user.id; const projectId = process.env.REVENUECAT_PROJECT_ID; // Fetch fresh data from RevenueCat V2 const rcData = await fetchCustomerDataV2(userId, projectId); if (!rcData) { return res.status(404).json({ error: 'Customer not found in RevenueCat' }); } // Sync to database await syncDatabaseWithRevenueCat(userId, rcData); return res.json({ message: 'Subscription synced successfully', isActive: hasActiveEntitlementV2(rcData), entitlements: rcData.active_entitlements?.items || [], syncedAt: new Date() }); } catch (error) { console.error('Error syncing with RevenueCat:', error); return res.status(500).json({ error: 'Failed to sync subscription' }); } } ``` ### Check Specific Entitlement ```javascript theme={null} // GET /api/entitlement/:entitlementId async function handleCheckEntitlement(req, res) { try { const userId = req.user.id; const entitlementId = req.params.entitlementId; const projectId = process.env.REVENUECAT_PROJECT_ID; // Check database first const dbStatus = await getSubscriptionStatusFromDB(userId); // If uncertain, verify with RevenueCat if (!dbStatus || shouldVerifyWithRevenueCat(dbStatus)) { const rcData = await fetchCustomerDataV2(userId, projectId); if (rcData) { await syncDatabaseWithRevenueCat(userId, rcData); const hasEntitlement = rcData.active_entitlements?.items?.some(ent => ent.entitlement_id === entitlementId && new Date(ent.expires_at) > new Date() ); return res.json({ hasAccess: hasEntitlement, entitlementId, source: 'revenuecat' }); } } // Use database status const entitlements = dbStatus?.entitlements || {}; const hasAccess = hasActiveEntitlement(entitlements, entitlementId); return res.json({ hasAccess, entitlementId, source: 'database' }); } catch (error) { console.error('Error checking entitlement:', error); return res.status(500).json({ error: 'Failed to check entitlement' }); } } ``` ### Debug Endpoint (Development Only) ```javascript theme={null} // GET /api/debug/subscription/:userId // WARNING: Only enable in development/staging async function handleDebugSubscription(req, res) { if (process.env.NODE_ENV === 'production') { return res.status(403).json({ error: 'Not available in production' }); } try { const userId = req.params.userId; const projectId = process.env.REVENUECAT_PROJECT_ID; // Get data from all sources const [dbStatus, rcV1Data, rcV2Data] = await Promise.all([ getSubscriptionStatusFromDB(userId), fetchSubscriberData(userId), fetchCustomerDataV2(userId, projectId) ]); return res.json({ database: dbStatus, revenueCatV1: rcV1Data, revenueCatV2: rcV2Data, comparison: { dbActive: dbStatus?.is_active, v1Active: rcV1Data?.entitlements ? Object.keys(rcV1Data.entitlements).length > 0 : false, v2Active: rcV2Data?.active_entitlements?.items?.length > 0, match: dbStatus?.is_active === (rcV2Data?.active_entitlements?.items?.length > 0) } }); } catch (error) { console.error('Error in debug endpoint:', error); return res.status(500).json({ error: error.message }); } } ``` ### Express.js Router Example ```javascript theme={null} const express = require('express'); const router = express.Router(); const { authenticate } = require('../middleware/auth'); // Webhook endpoint (no auth required) router.post('/webhooks/revenuecat', handleRevenueCatWebhook); // Protected endpoints (require authentication) router.get('/subscription-status', authenticate, handleSubscriptionStatus); router.post('/sync-revenuecat', authenticate, handleForceSync); router.get('/entitlement/:entitlementId', authenticate, handleCheckEntitlement); // Debug endpoint (development only) if (process.env.NODE_ENV !== 'production') { router.get('/debug/subscription/:userId', handleDebugSubscription); } module.exports = router; ``` ### Next.js API Routes Example ```javascript theme={null} // pages/api/subscription-status.js import { verifyAndSyncSubscriptionStatus } from '@/lib/revenuecat'; import { getServerSession } from 'next-auth'; export default async function handler(req, res) { if (req.method !== 'GET') { return res.status(405).json({ error: 'Method not allowed' }); } const session = await getServerSession(req, res); if (!session) { return res.status(401).json({ error: 'Unauthorized' }); } const status = await verifyAndSyncSubscriptionStatus( session.user.id, process.env.REVENUECAT_PROJECT_ID ); return res.json(status); } ``` ```javascript theme={null} // pages/api/sync-revenuecat.js import { fetchCustomerDataV2, syncDatabaseWithRevenueCat } from '@/lib/revenuecat'; import { getServerSession } from 'next-auth'; export default async function handler(req, res) { if (req.method !== 'POST') { return res.status(405).json({ error: 'Method not allowed' }); } const session = await getServerSession(req, res); if (!session) { return res.status(401).json({ error: 'Unauthorized' }); } const rcData = await fetchCustomerDataV2( session.user.id, process.env.REVENUECAT_PROJECT_ID ); if (!rcData) { return res.status(404).json({ error: 'Customer not found' }); } await syncDatabaseWithRevenueCat(session.user.id, rcData); return res.json({ message: 'Synced successfully', syncedAt: new Date() }); } ``` *** ## Sample Webhook Events ### Understanding Event Structure All RevenueCat webhooks follow this structure: ```json theme={null} { "api_version": "1.0", "event": { "id": "unique_event_id", "type": "INITIAL_PURCHASE", "app_user_id": "user_abc123", "aliases": ["$RCAnonymousID:abc123"], "original_app_user_id": "user_abc123", "product_id": "yearly_pro", "period_type": "normal", "purchased_at_ms": 1737115200000, "expiration_at_ms": 1768651200000, "environment": "PRODUCTION", "store": "app_store", "entitlements": { "pro": { "expires_date": "2026-01-17T12:00:00Z", "product_identifier": "yearly_pro", "purchase_date": "2025-01-17T12:00:00Z" } }, "subscriber_attributes": { "$email": { "value": "user@example.com", "updated_at_ms": 1737115200000 }, "$displayName": { "value": "John Doe", "updated_at_ms": 1737115200000 } }, "event_timestamp_ms": 1737115200000 } } ``` ### Key Fields to Extract | Field | Location | Purpose | Example | | :--------------- | :----------------------------------------- | :----------------- | :------------------- | | **Event ID** | `event.id` | Idempotency | `"unique_event_id"` | | **Event Type** | `event.type` | Determine action | `"INITIAL_PURCHASE"` | | **User ID** | `event.app_user_id` | Identify user | `"user_abc123"` | | **Product ID** | `event.product_id` | What was purchased | `"yearly_pro"` | | **Entitlements** | `event.entitlements` | Active access | `{ "pro": {...} }` | | **Email** | `event.subscriber_attributes.$email.value` | Contact user | `"user@example.com"` | | **Expires At** | `event.expiration_at_ms` | When access ends | `1768651200000` | | **Environment** | `event.environment` | Production/Sandbox | `"PRODUCTION"` | | **Store** | `event.store` | Purchase platform | `"app_store"` | ### Sample: Initial Purchase ```json theme={null} { "api_version": "1.0", "event": { "id": "abc123-def456-ghi789", "type": "INITIAL_PURCHASE", "app_user_id": "user_123", "original_app_user_id": "user_123", "product_id": "yearly_pro", "period_type": "normal", "purchased_at_ms": 1737115200000, "expiration_at_ms": 1768651200000, "environment": "PRODUCTION", "store": "app_store", "entitlements": { "pro": { "expires_date": "2026-01-17T12:00:00Z", "grace_period_expires_date": null, "product_identifier": "yearly_pro", "purchase_date": "2025-01-17T12:00:00Z" } }, "subscriber_attributes": { "$email": { "value": "user@example.com", "updated_at_ms": 1737115200000 } }, "event_timestamp_ms": 1737115200000, "price": 99.99, "currency": "USD", "is_trial_conversion": false, "takehome_percentage": 0.85 } } ``` **Extract:** * User: `event.app_user_id` → `"user_123"` * Email: `event.subscriber_attributes.$email.value` → `"user@example.com"` * Entitlements: `event.entitlements` → `{ "pro": {...} }` * Active: Check if `entitlements.pro.expires_date` > now * Product: `event.product_id` → `"yearly_pro"` ### Sample: Renewal ```json theme={null} { "api_version": "1.0", "event": { "id": "renewal-event-123", "type": "RENEWAL", "app_user_id": "user_123", "product_id": "yearly_pro", "period_type": "normal", "purchased_at_ms": 1768651200000, "expiration_at_ms": 1800187200000, "environment": "PRODUCTION", "store": "app_store", "entitlements": { "pro": { "expires_date": "2027-01-17T12:00:00Z", "product_identifier": "yearly_pro", "purchase_date": "2026-01-17T12:00:00Z" } }, "event_timestamp_ms": 1768651200000, "price": 99.99, "currency": "USD" } } ``` **Action:** Update `expires_at` to new date, keep entitlements active. ### Sample: Cancellation ```json theme={null} { "api_version": "1.0", "event": { "id": "cancel-event-123", "type": "CANCELLATION", "app_user_id": "user_123", "product_id": "yearly_pro", "expiration_at_ms": 1768651200000, "environment": "PRODUCTION", "store": "app_store", "entitlements": { "pro": { "expires_date": "2026-01-17T12:00:00Z", "product_identifier": "yearly_pro", "purchase_date": "2025-01-17T12:00:00Z" } }, "cancellation_reason": "UNSUBSCRIBE", "event_timestamp_ms": 1737202800000 } } ``` **Important:** * Entitlements STILL ACTIVE until `expires_date` * Update status to "cancelled" * Schedule access removal at `expiration_at_ms` ### Sample: Expiration ```json theme={null} { "api_version": "1.0", "event": { "id": "expire-event-123", "type": "EXPIRATION", "app_user_id": "user_123", "product_id": "yearly_pro", "expiration_at_ms": 1768651200000, "environment": "PRODUCTION", "store": "app_store", "entitlements": {}, "expiration_reason": "UNSUBSCRIBE", "event_timestamp_ms": 1768651200000 } } ``` **Action:** Remove all access - `entitlements` is now empty `{}` ### Sample: Billing Issue ```json theme={null} { "api_version": "1.0", "event": { "id": "billing-event-123", "type": "BILLING_ISSUE", "app_user_id": "user_123", "product_id": "yearly_pro", "expiration_at_ms": 1768651200000, "environment": "PRODUCTION", "store": "app_store", "entitlements": { "pro": { "expires_date": "2026-01-17T12:00:00Z", "grace_period_expires_date": "2026-01-24T12:00:00Z", "product_identifier": "yearly_pro", "purchase_date": "2025-01-17T12:00:00Z" } }, "grace_period_expires_at_ms": 1769256000000, "event_timestamp_ms": 1768651200000 } } ``` **Action:** * Entitlements STILL ACTIVE during grace period * Flag account with billing issue * Access expires at `grace_period_expires_date` if not resolved ### Sample: Product Change ```json theme={null} { "api_version": "1.0", "event": { "id": "product-change-123", "type": "PRODUCT_CHANGE", "app_user_id": "user_123", "product_id": "monthly_pro", "new_product_id": "yearly_pro", "expiration_at_ms": 1800187200000, "environment": "PRODUCTION", "store": "app_store", "entitlements": { "pro": { "expires_date": "2027-01-17T12:00:00Z", "product_identifier": "yearly_pro", "purchase_date": "2026-01-17T12:00:00Z" } }, "event_timestamp_ms": 1768651200000 } } ``` **Action:** * Update `product_id` to `new_product_id` * Entitlements remain active * Update expiration date ### Entitlement Extraction Code ```json theme={null} // Extract entitlements from any webhook event function extractEntitlementsFromEvent(event) { const entitlements = event.entitlements || {}; const activeEntitlements = {}; Object.keys(entitlements).forEach(entitlementId => { const ent = entitlements[entitlementId]; // Check if still active const expiresDate = ent.expires_date; const isActive = !expiresDate || new Date(expiresDate) > new Date(); if (isActive) { activeEntitlements[entitlementId] = { entitlement_id: entitlementId, product_id: ent.product_identifier, expires_at: expiresDate, purchase_date: ent.purchase_date, grace_period_expires_at: ent.grace_period_expires_date }; } }); return activeEntitlements; } // Usage in webhook handler const activeEntitlements = extractEntitlementsFromEvent(event.event); const hasAccess = Object.keys(activeEntitlements).length > 0; // Store in database await db.query(` UPDATE user_subscriptions SET entitlements = $1, is_active = $2, last_updated = NOW() WHERE app_user_id = $3 `, [ JSON.stringify(activeEntitlements), hasAccess, event.event.app_user_id ]); ``` ### Common Patterns **Check if user has specific entitlement:** ```javascript theme={null} function hasEntitlement(activeEntitlements, entitlementId) { return activeEntitlements[entitlementId] !== undefined; } const hasPro = hasEntitlement(activeEntitlements, 'pro'); ``` **Check if ANY entitlement is active:** ```javascript theme={null} const hasAnyAccess = Object.keys(activeEntitlements).length > 0; ``` **Get all active entitlement IDs:** ```javascript theme={null} const activeIds = Object.keys(activeEntitlements); // ["pro", "premium_features"] ``` **Get expiration dates:** ```javascript theme={null} const expirationDates = Object.values(activeEntitlements).map(ent => ({ id: ent.entitlement_id, expires: ent.expires_at })); ``` *** ## Entitlement Sync Strategy ### Why Sync is Critical Your database must stay synchronized with RevenueCat to provide accurate access control. Out-of-sync entitlements cause: * Users losing access they paid for (churn) * Users gaining unauthorized access (revenue loss) * Poor user experience * Support tickets ### Three-Layer Sync Architecture ```mermaid theme={null} flowchart TD RC[RevenueCat - Source of Truth
• App Store / Play Store / Stripe
• Handles refunds, renewals, cancellations] RC -->|Webhooks - Primary Sync
INITIAL_PURCHASE
RENEWAL
CANCELLATION
EXPIRATION
etc.| DB DB[Your Database - Cached State] subgraph DB_Tables [" "] T1[user_subscriptions
• is_active
• entitlements JSON
• expires_at
• product_id] T2[user_entitlements
• entitlement_id
• expires_at
• is_active
• product_id] end DB -.-> DB_Tables DB -->|API V2 - Backup Verification
When:
• Webhook missed
• Near expiration
• Billing issues
• User-triggered check| APP APP[Your Application
• Feature gates
• Access control
• UI rendering] style RC fill:#e1f5e1 style DB fill:#fff4e1 style APP fill:#e1f0ff style T1 fill:#f0f0f0 style T2 fill:#f0f0f0 style DB_Tables fill:none,stroke:none ``` ### Sync Flow Per Webhook **Every webhook event follows this flow:** ```javascript theme={null} // 1. Webhook arrives POST /webhooks/revenuecat { "event": { "type": "RENEWAL", "app_user_id": "user_123", "entitlements": { "pro": { "expires_date": "2026-01-17T12:00:00Z", "product_identifier": "yearly_pro", "purchase_date": "2025-01-17T12:00:00Z" } }, ... } } // 2. Extract entitlements const entitlementData = extractEntitlementData(event); // Returns: { // entitlements: { "pro": {...} }, // productId: "yearly_pro", // expirationAtMs: 1768651200000, // ... // } // 3. Sync to database (CRITICAL - happens for EVERY event) await syncEntitlementsToDatabase(appUserId, entitlementData, event); // Updates: // - user_subscriptions.entitlements = { "pro": {...} } // - user_subscriptions.is_active = true // - user_subscriptions.expires_at = "2026-01-17" // - user_entitlements rows // 4. Process event-specific logic switch(event.type) { case 'RENEWAL': // Send thank you email // Update analytics break; ... } // 5. Return 200 OK to RevenueCat ``` ### Handling Missed Webhooks Even with webhooks, you need backup verification: ```javascript theme={null} async function getUserAccess(userId) { // 1. Check database (fast) const dbStatus = await getUserEntitlements(userId); // 2. Determine if verification needed const needsVerification = shouldVerifyWithRevenueCat({ expires_at: dbStatus.expires_at, billing_issue: dbStatus.billing_issue, last_updated: dbStatus.last_updated }); if (!needsVerification) { return dbStatus; // Trust database } // 3. Verify with RevenueCat V2 API const rcData = await fetchCustomerDataV2(userId, projectId); // 4. Detect mismatch const dbHasAccess = Object.keys(dbStatus.entitlements).length > 0; const rcHasAccess = rcData?.active_entitlements?.items?.length > 0; if (dbHasAccess !== rcHasAccess) { console.warn('Entitlement mismatch - syncing from RevenueCat'); // 5. Sync database with source of truth await syncDatabaseWithRevenueCat(userId, rcData); } return { entitlements: rcData.active_entitlements?.items || [], source: 'revenuecat', synced: true }; } ``` ### Critical Sync Points **ALWAYS sync entitlements on these events:** | Event | Database Update | Notes | | :---------------- | :----------------------------------- | :------------------------ | | INITIAL\_PURCHASE | Add entitlements | Grant access immediately | | RENEWAL | Update expires\_at | Extend access | | CANCELLATION | Keep entitlements, set cancelled\_at | Access until expiration | | EXPIRATION | Clear entitlements | Remove access | | BILLING\_ISSUE | Keep entitlements, set grace period | Access during grace | | PRODUCT\_CHANGE | Update entitlements & product\_id | May change access level | | REFUND | Clear entitlements | Revoke access immediately | | REFUND\_REVERSED | Restore entitlements | Re-grant access | ### Example: Cancellation Sync ```javascript theme={null} // Webhook: CANCELLATION { "type": "CANCELLATION", "app_user_id": "user_123", "entitlements": { "pro": { "expires_date": "2026-01-17T12:00:00Z", // STILL ACTIVE "product_identifier": "yearly_pro" } }, "expiration_at_ms": 1768651200000 } // Database update await db.query(` UPDATE user_subscriptions SET subscription_status = 'cancelled', cancelled_at = NOW(), auto_renew = false, entitlements = $1, -- KEEP entitlements (still has access) expires_at = $2, -- Access until this date last_updated = NOW() WHERE app_user_id = $3 `, [ JSON.stringify({ "pro": {...} }), // Entitlements still active new Date(1768651200000), // Expiration date "user_123" ]); // User still has access until expiration_at // On expiration date, EXPIRATION webhook will: // - Clear entitlements: {} // - Set is_active = false ``` ### Example: Expiration Sync ```javascript theme={null} // Webhook: EXPIRATION { "type": "EXPIRATION", "app_user_id": "user_123", "entitlements": {}, // EMPTY - no more access "expiration_reason": "UNSUBSCRIBE" } // Database update await db.query(` UPDATE user_subscriptions SET subscription_status = 'expired', is_active = false, entitlements = '{}', -- Clear entitlements auto_renew = false, last_updated = NOW() WHERE app_user_id = $1 `, ["user_123"]); // Also deactivate in normalized table await db.query(` UPDATE user_entitlements SET is_active = false, updated_at = NOW() WHERE app_user_id = $1 `, ["user_123"]); // User loses access immediately ``` ### Sync Verification Schedule **When to verify with RevenueCat API:** ```javascript theme={null} function shouldVerifyWithRevenueCat(dbStatus) { if (!dbStatus) return true; // No data // 1. Expires within 24 hours if (dbStatus.expires_at) { const hoursUntilExpiry = (new Date(dbStatus.expires_at) - new Date()) / (1000 * 60 * 60); if (hoursUntilExpiry < 24 && hoursUntilExpiry > 0) { return true; // Verify near expiration } } // 2. Billing issue active if (dbStatus.billing_issue) { return true; // May have been resolved } // 3. Recently cancelled (within 48 hours) if (dbStatus.cancelled_at) { const hoursSinceCancel = (new Date() - new Date(dbStatus.cancelled_at)) / (1000 * 60 * 60); if (hoursSinceCancel < 48) { return true; // User might uncancelled } } // 4. Cache stale (> 1 hour old) if (dbStatus.last_updated) { const hoursSinceUpdate = (new Date() - new Date(dbStatus.last_updated)) / (1000 * 60 * 60); if (hoursSinceUpdate > 1) { return true; // Refresh stale cache } } return false; // Trust database } ``` ### Monitoring Sync Health Track sync issues in your application: ```javascript theme={null} // Track when database and RevenueCat disagree async function logSyncDiscrepancy(userId, dbStatus, rcStatus) { await db.query(` INSERT INTO sync_discrepancies ( app_user_id, db_is_active, rc_is_active, db_entitlements, rc_entitlements, detected_at ) VALUES ($1, $2, $3, $4, $5, NOW()) `, [ userId, dbStatus.is_active, rcStatus.active_entitlements?.items?.length > 0, JSON.stringify(dbStatus.entitlements), JSON.stringify(rcStatus.active_entitlements?.items) ]); } // Alert on frequent discrepancies const discrepancyRate = await db.query(` SELECT COUNT(*) as count FROM sync_discrepancies WHERE detected_at > NOW() - INTERVAL '1 hour' `); if (discrepancyRate.rows[0].count > 10) { // Send alert - webhooks may be failing await sendAlert('High entitlement sync discrepancy rate'); } ``` *** ## Working with Entitlements ### Understanding Entitlements vs Products **Product ID** = What the user purchased (e.g., "monthly\_pro", "yearly\_premium")\ **Entitlement ID** = What access they have (e.g., "pro", "premium\_features") One product can grant multiple entitlements. Multiple products can grant the same entitlement. ```javascript theme={null} // Example: Both products grant the same "pro" entitlement { "monthly_pro": { entitlements: ["pro"] }, "yearly_pro": { entitlements: ["pro"] } } ``` ### Check User Access ```javascript theme={null} // Method 1: Check database (fast) async function checkUserAccess(userId, requiredEntitlement) { const result = await db.query(` SELECT 1 FROM user_entitlements WHERE app_user_id = $1 AND entitlement_id = $2 AND is_active = true AND (expires_at IS NULL OR expires_at > NOW()) `, [userId, requiredEntitlement]); return result.rows.length > 0; } // Method 2: Check RevenueCat API (authoritative) async function checkUserAccessFromRC(userId, projectId, requiredEntitlement) { const rcData = await fetchCustomerDataV2(userId, projectId); if (!rcData?.active_entitlements?.items) { return false; } return rcData.active_entitlements.items.some(ent => ent.entitlement_id === requiredEntitlement && new Date(ent.expires_at) > new Date() ); } // Method 3: Combined (self-healing) async function checkUserAccessSafe(userId, projectId, requiredEntitlement) { // Check database first const hasAccessDB = await checkUserAccess(userId, requiredEntitlement); // If database says no access, verify with RevenueCat if (!hasAccessDB) { return await checkUserAccessFromRC(userId, projectId, requiredEntitlement); } return true; } ``` ### Middleware for Route Protection ```javascript theme={null} // Express middleware function requireEntitlement(entitlementId) { return async (req, res, next) => { const userId = req.user?.id; if (!userId) { return res.status(401).json({ error: 'Not authenticated' }); } const hasAccess = await userHasEntitlement(userId, entitlementId); if (!hasAccess) { return res.status(403).json({ error: 'Access denied', required_entitlement: entitlementId }); } next(); }; } // Usage app.get('/api/premium-feature', requireEntitlement('pro'), handlePremiumFeature ); ``` ### Frontend Entitlement Check ```javascript theme={null} // API endpoint for frontend app.get('/api/my-entitlements', async (req, res) => { const userId = req.user.id; // Get from database const entitlements = await getUserEntitlements(userId); // Return entitlement list return res.json({ entitlements: Object.keys(entitlements), details: entitlements }); }); // Frontend usage (React example) function useEntitlements() { const [entitlements, setEntitlements] = useState([]); useEffect(() => { fetch('/api/my-entitlements') .then(res => res.json()) .then(data => setEntitlements(data.entitlements)); }, []); const hasAccess = (entitlementId) => { return entitlements.includes(entitlementId); }; return { entitlements, hasAccess }; } // Component function PremiumFeature() { const { hasAccess } = useEntitlements(); if (!hasAccess('pro')) { return ; } return ; } ``` ### Entitlement-Based Feature Flags ```javascript theme={null} const FEATURES = { 'advanced_analytics': ['pro', 'enterprise'], 'api_access': ['enterprise'], 'unlimited_exports': ['pro', 'enterprise'], 'priority_support': ['enterprise'] }; function userCanAccessFeature(userEntitlements, featureId) { const requiredEntitlements = FEATURES[featureId]; if (!requiredEntitlements) { return true; // Feature not gated } return requiredEntitlements.some(entId => userEntitlements.includes(entId) ); } // Usage const userEntitlements = ['pro']; const canExport = userCanAccessFeature(userEntitlements, 'unlimited_exports'); // true const hasAPI = userCanAccessFeature(userEntitlements, 'api_access'); // false ``` ### Sync Entitlements After Webhook ```javascript theme={null} async function handleInitialPurchase(data) { const { appUserId, activeEntitlements, event } = data; // 1. Sync to user_subscriptions (JSONB column) await syncEntitlementsToDatabase(appUserId, activeEntitlements, event); // 2. Sync to user_entitlements (normalized table) await syncEntitlementsToNormalizedTable(appUserId, activeEntitlements, event); // 3. Grant access in your app const entitlementIds = Object.keys(activeEntitlements); await grantFeatureAccess(appUserId, entitlementIds); // 4. Send confirmation email if (data.externalUserId) { await sendWelcomeEmail(data.externalUserId, entitlementIds); } console.log(`Granted entitlements to ${appUserId}:`, entitlementIds); } ``` ### Query Active Users by Entitlement ```javascript theme={null} // Get all users with specific entitlement async function getUsersWithEntitlement(entitlementId) { const result = await db.query(` SELECT u.app_user_id, u.email, e.expires_at FROM user_subscriptions u JOIN user_entitlements e ON u.app_user_id = e.app_user_id WHERE e.entitlement_id = $1 AND e.is_active = true AND (e.expires_at IS NULL OR e.expires_at > NOW()) ORDER BY e.expires_at DESC `, [entitlementId]); return result.rows; } // Get users expiring soon async function getUsersExpiringSoon(entitlementId, daysUntilExpiry = 7) { const result = await db.query(` SELECT u.app_user_id, u.email, e.expires_at, EXTRACT(DAY FROM (e.expires_at - NOW())) as days_until_expiry FROM user_subscriptions u JOIN user_entitlements e ON u.app_user_id = e.app_user_id WHERE e.entitlement_id = $1 AND e.is_active = true AND e.expires_at > NOW() AND e.expires_at < NOW() + INTERVAL '1 day' * $2 ORDER BY e.expires_at ASC `, [entitlementId, daysUntilExpiry]); return result.rows; } ``` ### Analytics and Reporting ```javascript theme={null} // Get entitlement breakdown async function getEntitlementStats() { const result = await db.query(` SELECT entitlement_id, COUNT(DISTINCT app_user_id) as user_count, COUNT(DISTINCT CASE WHEN environment = 'PRODUCTION' THEN app_user_id END) as production_users, COUNT(DISTINCT CASE WHEN environment = 'SANDBOX' THEN app_user_id END) as sandbox_users FROM user_entitlements WHERE is_active = true AND (expires_at IS NULL OR expires_at > NOW()) GROUP BY entitlement_id ORDER BY user_count DESC `); return result.rows; } // Get subscription revenue metrics async function getSubscriptionMetrics() { const result = await db.query(` SELECT product_id, COUNT(*) as active_subscriptions, COUNT(CASE WHEN billing_issue = true THEN 1 END) as billing_issues, COUNT(CASE WHEN subscription_status = 'cancelled' THEN 1 END) as cancelled, AVG(EXTRACT(EPOCH FROM (expires_at - NOW())) / 86400) as avg_days_remaining FROM user_subscriptions WHERE is_active = true GROUP BY product_id ORDER BY active_subscriptions DESC `); return result.rows; } ``` *** ## Additional Resources * [RevenueCat Webhooks Documentation](https://www.revenuecat.com/docs/integrations/webhooks) * [Event Types Reference](https://www.revenuecat.com/docs/integrations/webhooks/event-types-and-fields) * [REST API Documentation (V1)](https://www.revenuecat.com/docs/api-v1) * [REST API Documentation (V2)](https://www.revenuecat.com/docs/api-v2) # User Session Source: https://setup.despia.com/best-practices/backend/user-session This guide covers the general user identity management pattern for Despia apps. This system is the foundation that all SDK integrations (RevenueCat, OneSignal, Stripe, etc.) build upon. ## Overview Every Despia app needs a single, consistent user identifier (`app_user_id`) that persists across sessions and devices. This identifier connects all your services together. ```mermaid placement="bottom-right" theme={null} flowchart TD ID[app_user_id] --> RC[RevenueCat] ID --> OS[OneSignal] ID --> ST[Stripe] ID --> DB[(Your Database)] ``` ## Identity Resolution On every app launch, resolve the user's identity using this priority: | Priority | Source | Description | | :------- | :--------------- | :-------------------------------------------------------------------------- | | 1 | Storage Vault | Synced via iCloud/Google backup | | 2 | Service Recovery | Recovers users via service-specific IDs (e.g., RevenueCat `externalUserId`) | | 3 | Install ID | Fallback for new users (`despia.uuid`) | ### Implementation ```javascript theme={null} import despia from 'despia-native'; // State tracking let identityReady = false; let databaseSynced = false; let appUserId = null; let installId = null; async function initializeIdentity() { const ua = navigator.userAgent.toLowerCase(); if (!ua.includes('despia')) { identityReady = true; databaseSynced = true; return null; } installId = despia.uuid; let source = 'new'; // Step 1: Attempt to read from Storage Vault try { const vaultData = await despia('readvault://?key=app_user_id', ['app_user_id']); if (vaultData?.app_user_id) { appUserId = vaultData.app_user_id; source = 'vault'; } } catch (error) { // Vault read failed, continue to next source } // Step 2: Attempt to recover from purchase history (RevenueCat) // ══════════════════════════════════════════════════════════════ // REVENUECAT INTEGRATION - Comment out this block if not using RevenueCat // ══════════════════════════════════════════════════════════════ if (!appUserId) { try { const data = await despia('getpurchasehistory://', ['restoredData']); const purchases = data.restoredData || []; // Prioritize active subscriptions, then most recent purchase const activePurchase = purchases.find(p => p.isActive && p.externalUserId); const recentPurchase = purchases .filter(p => p.externalUserId) .sort((a, b) => new Date(b.purchaseDate) - new Date(a.purchaseDate))[0]; const recoveredId = activePurchase?.externalUserId || recentPurchase?.externalUserId; if (recoveredId) { appUserId = recoveredId; source = 'restore'; // Lock identity to prevent accidental changes await despia('setvault://?key=identity_locked&value=true&locked=false'); } } catch (error) { // Purchase history unavailable, continue to fallback } } // ══════════════════════════════════════════════════════════════ // END REVENUECAT INTEGRATION // ══════════════════════════════════════════════════════════════ // Step 3: Fall back to install ID for new users if (!appUserId) { appUserId = installId; source = 'new'; } // Step 4: Persist to Storage Vault try { await despia(`setvault://?key=app_user_id&value=${appUserId}&locked=false`); } catch (error) { // Vault write failed, continue with sync } identityReady = true; // Step 5: Sync to OneSignal // ══════════════════════════════════════════════════════════════ // ONESIGNAL INTEGRATION - Comment out if not using OneSignal // ══════════════════════════════════════════════════════════════ despia(`setonesignalplayerid://?user_id=${appUserId}`); // ══════════════════════════════════════════════════════════════ // Step 6: Sync to backend database (required for webhooks) try { await fetch('/api/user/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ appUserId, deviceId: installId, source, platform: ua.includes('android') ? 'android' : 'ios', timestamp: new Date().toISOString() }) }); databaseSynced = true; } catch (error) { // Queue for retry on next launch const queue = JSON.parse(localStorage.getItem('identity_sync_queue') || '[]'); queue.push({ appUserId, installId, source, queuedAt: Date.now() }); localStorage.setItem('identity_sync_queue', JSON.stringify(queue)); // Allow app to continue; webhook safety net will handle missing users databaseSynced = true; } return { appUserId, installId, source }; } ``` > **Important:** The RevenueCat purchase history recovery (Step 2) is critical for apps with in-app purchases. It allows paying users to recover their identity on new devices. If you're not using RevenueCat, comment out that block and the identity will fall through to the install ID. ### Flow Diagram ```mermaid theme={null} flowchart TD Start([App Launch]) --> GetUUID[Get install_id from despia.uuid] GetUUID --> Vault[Read from Storage Vault] Vault --> VaultCheck{Found?} VaultCheck -->|Yes| UseVault[Use vault ID] VaultCheck -->|No| Restore[Query purchase history
getpurchasehistory://] Restore --> RestoreCheck{Found externalUserId?} RestoreCheck -->|Yes| UseRestore[Use restored ID] RestoreCheck -->|No| UseInstall[Use install_id] UseRestore --> Lock[Lock identity
identity_locked=true] Lock --> Persist UseVault --> Persist[Persist to vault] UseInstall --> Persist Persist --> SyncOS[Sync to OneSignal
setonesignalplayerid://] SyncOS --> SyncDB[Sync to database
POST /api/user/register] SyncDB --> Ready([Paywall can open]) ``` > **Note:** The `getpurchasehistory://` step is RevenueCat-specific. If not using RevenueCat, the flow goes directly from "Vault not found" to "Use install\_id". ## Paywall Readiness Before launching a paywall, ensure both identity resolution and database sync are complete: ```javascript theme={null} // Check readiness before opening paywall function canOpenPaywall() { return identityReady && databaseSynced && appUserId; } // Launch paywall with identity function launchPaywall(offering = 'default') { if (!canOpenPaywall()) { console.warn('Identity not ready, cannot open paywall'); return; } despia(`revenuecat://launchPaywall?external_id=${appUserId}&offering=${offering}`); } // Handle purchase callback (set globally) window.onRevenueCatPurchase = async (purchaseData) => { // Don't grant access immediately - wait for webhook confirmation // Start polling backend for status update pollForSubscriptionStatus(appUserId); }; ``` ## User Login (Claim vs Recover) When a user authenticates, the backend determines whether to **claim** the current identity or **recover** an existing one. ### Login Actions | Action | Condition | Result | | :---------- | :-------------------------------- | :------------------------------------ | | **CLAIM** | Account has no `app_user_id` | Link current `app_user_id` to account | | **RECOVER** | Account already has `app_user_id` | Return existing `app_user_id` | ### Client-Side Implementation ```javascript theme={null} async function handleUserLogin(accountId, credentials) { const response = await fetch('/api/user/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ accountId, currentAppUserId: appUserId, credentials }) }); if (!response.ok) { throw new Error('Login failed'); } const { appUserId: returnedId, action } = await response.json(); // Update Storage Vault await despia(`setvault://?key=app_user_id&value=${returnedId}&locked=false`); // Sync to all services await syncToServices(returnedId); // Update local state appUserId = returnedId; return { appUserId: returnedId, action }; } ``` ## Backend API Endpoints ### User Registration Endpoint This endpoint receives identity syncs from the app on every launch: ```javascript theme={null} // POST /api/user/register async function registerHandler(req, res) { const { appUserId, deviceId, source, platform, timestamp } = req.body; try { // Upsert user record await db.query(` INSERT INTO users (app_user_id, created_at, last_seen) VALUES ($1, $2, $2) ON CONFLICT (app_user_id) DO UPDATE SET last_seen = $2 `, [appUserId, timestamp]); // Track device await db.query(` INSERT INTO user_devices (device_id, app_user_id, platform, last_seen, created_at) VALUES ($1, $2, $3, $4, $4) ON CONFLICT (device_id) DO UPDATE SET app_user_id = $2, last_seen = $4 `, [deviceId, appUserId, platform, timestamp]); // Log identity source for analytics if (source === 'restore') { console.log(`User ${appUserId} recovered via purchase history`); } res.json({ success: true }); } catch (error) { console.error('Registration failed:', error); res.status(500).json({ error: 'Registration failed' }); } } ``` ### User Login Endpoint ```javascript theme={null} // POST /api/user/login async function loginHandler(req, res) { const { accountId, currentAppUserId, credentials } = req.body; // Authenticate user const account = await authenticateUser(accountId, credentials); if (!account) { return res.status(401).json({ error: 'Invalid credentials' }); } // Identity resolution if (!account.app_user_id) { // CLAIM: Link current identity to account await db.accounts.update(accountId, { app_user_id: currentAppUserId }); return res.json({ appUserId: currentAppUserId, action: 'claimed' }); } else { // RECOVER: Return existing identity return res.json({ appUserId: account.app_user_id, action: 'recovered' }); } } ``` ### Login Flow Diagram ```mermaid theme={null} sequenceDiagram autonumber participant App participant API participant Vault participant Services Note over App,Services: Authentication App->>API: POST /login Note over App,API: + currentAppUserId API->>API: Query account Note over App,Services: Identity Resolution alt No app_user_id Note over API: CLAIM API->>API: Save app_user_id API-->>App: action: claimed else Has app_user_id Note over API: RECOVER API-->>App: action: recovered end Note over App,Services: State Sync App->>Vault: Write app_user_id Vault-->>App: OK App->>Services: Sync to all services Services-->>App: OK Note over App,Services: Done ``` ## Syncing to Services After identity resolution, sync the `app_user_id` to all integrated services. ### Generic Sync Pattern ```javascript theme={null} async function syncToServices(appUserId) { // OneSignal (push notifications) despia(`setonesignalplayerid://?user_id=${appUserId}`); // Your backend database await fetch('/api/user/sync', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ appUserId, deviceId: despia.uuid, timestamp: new Date().toISOString() }) }); // RevenueCat: passed via external_id when launching paywall // Stripe: sync via backend webhook } ``` ## Restore Purchases (Required for App Store) Provide a restore purchases button for users who need to recover their subscriptions on a new device. This is **required** by both Apple and Google. ```javascript theme={null} async function restorePurchases() { try { const data = await despia('getpurchasehistory://', ['restoredData']); const purchases = data.restoredData || []; // Find any purchase with an externalUserId const purchaseWithId = purchases.find(p => p.externalUserId); if (purchaseWithId) { const recoveredId = purchaseWithId.externalUserId; // Update Storage Vault await despia(`setvault://?key=app_user_id&value=${recoveredId}&locked=false`); await despia('setvault://?key=identity_locked&value=true&locked=false'); // Sync to OneSignal despia(`setonesignalplayerid://?user_id=${recoveredId}`); // Update local state appUserId = recoveredId; // Sync to backend await fetch('/api/user/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ appUserId: recoveredId, deviceId: installId, source: 'restore_button', timestamp: new Date().toISOString() }) }); return { success: true, appUserId: recoveredId }; } return { success: false, message: 'No purchases found' }; } catch (error) { return { success: false, error: error.message }; } } ``` ### Where to Place the Restore Button * Settings screen (most common) * Paywall screen * Account or profile screen * Onboarding flow for returning users ## Integration Examples ### RevenueCat (In-App Purchases) — Deep Integration RevenueCat is deeply integrated into Despia. The `app_user_id` is passed as `external_id` when launching paywalls. ```javascript theme={null} // Launch native paywall despia(`revenuecat://launchPaywall?external_id=${appUserId}&offering=default`); // Purchase a specific product despia(`revenuecat://purchase?external_id=${appUserId}&product=monthly_premium`); // Check entitlement status async function checkPremiumAccess() { const history = await despia('getpurchasehistory://', ['restoredData']); const activeSubs = history.restoredData.filter(p => p.isActive && p.entitlementId === 'premium' ); return activeSubs.length > 0; } ``` #### Purchase Response Fields The `getpurchasehistory://` response includes: | Field | Description | | :---------------------- | :----------------------------------------------- | | `transactionId` | Unique identifier for this transaction | | `originalTransactionId` | Links to original purchase (for renewals) | | `productId` | Product ID from App Store Connect / Play Console | | `type` | `"subscription"` or `"product"` | | `entitlementId` | The entitlement this purchase grants | | `isActive` | Whether purchase currently grants access | | `willRenew` | Whether subscription will auto-renew | | `purchaseDate` | ISO timestamp of transaction | | `expirationDate` | When access expires (null for lifetime) | | `externalUserId` | The `app_user_id` linked to this purchase | | `store` | `"app_store"` or `"play_store"` | #### Hybrid Payment Example (Native + Web) ```javascript theme={null} const handleUpgrade = () => { if (navigator.userAgent.includes('despia')) { // Native: Use RevenueCat despia(`revenuecat://launchPaywall?external_id=${appUserId}&offering=default`); } else { // Web fallback: Redirect to Stripe window.location.href = `/pricing?user=${appUserId}`; } }; ``` ### OneSignal (Push Notifications) ```javascript theme={null} // Set external user ID on every app load despia(`setonesignalplayerid://?user_id=${appUserId}`); ``` ### Stripe (Web-Based Payments) For web-based services like Stripe, sync via your backend: ```javascript theme={null} // Client: Pass app_user_id to your backend async function createCheckoutSession(priceId) { const response = await fetch('/api/stripe/checkout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ appUserId, priceId, deviceId: despia.uuid }) }); const { sessionUrl } = await response.json(); window.location.href = sessionUrl; } ``` ```javascript theme={null} // Backend: Create Stripe customer with app_user_id as metadata async function createCheckoutHandler(req, res) { const { appUserId, priceId, deviceId } = req.body; // Find or create Stripe customer let customer = await findStripeCustomerByAppUserId(appUserId); if (!customer) { customer = await stripe.customers.create({ metadata: { app_user_id: appUserId, device_id: deviceId } }); await saveStripeCustomerMapping(appUserId, customer.id); } // Create checkout session const session = await stripe.checkout.sessions.create({ customer: customer.id, line_items: [{ price: priceId, quantity: 1 }], mode: 'subscription', success_url: 'myapp://payment/success', cancel_url: 'myapp://payment/cancel', metadata: { app_user_id: appUserId } }); res.json({ sessionUrl: session.url }); } ``` ```javascript theme={null} // Backend: Handle Stripe webhook async function stripeWebhookHandler(req, res) { const event = stripe.webhooks.constructEvent( req.body, req.headers['stripe-signature'], webhookSecret ); switch (event.type) { case 'checkout.session.completed': const session = event.data.object; const appUserId = session.metadata.app_user_id; // Update user subscription status await updateUserSubscription(appUserId, { stripeCustomerId: session.customer, subscriptionId: session.subscription, status: 'active' }); break; } res.json({ received: true }); } ``` ### Generic Web SDK Pattern For any web-based SDK, follow this pattern: ```javascript theme={null} // 1. Client passes app_user_id to backend await fetch('/api/service/action', { method: 'POST', body: JSON.stringify({ appUserId, deviceId: despia.uuid, // ... service-specific params }) }); // 2. Backend links service's user ID to app_user_id async function serviceHandler(req, res) { const { appUserId } = req.body; // Create or find service user const serviceUser = await service.createUser({ externalId: appUserId, // or metadata: { app_user_id: appUserId } }); // Store mapping await saveServiceMapping(appUserId, serviceUser.id); } // 3. Backend handles webhooks using app_user_id async function serviceWebhook(req, res) { const event = req.body; const appUserId = event.metadata.app_user_id || await getAppUserIdFromServiceId(event.user_id); // Update your database await updateUser(appUserId, { /* ... */ }); } ``` ## Database Schema Store the `app_user_id` as the primary link across all tables: ```javascript theme={null} -- Users table CREATE TABLE users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), app_user_id TEXT UNIQUE NOT NULL, email TEXT, created_at TIMESTAMP DEFAULT NOW() ); -- Service mappings CREATE TABLE service_mappings ( app_user_id TEXT REFERENCES users(app_user_id), service_name TEXT NOT NULL, service_user_id TEXT NOT NULL, created_at TIMESTAMP DEFAULT NOW(), PRIMARY KEY (app_user_id, service_name) ); -- Example: devices CREATE TABLE user_devices ( device_id TEXT PRIMARY KEY, app_user_id TEXT REFERENCES users(app_user_id), last_seen TIMESTAMP, created_at TIMESTAMP DEFAULT NOW() ); ``` ## Summary | Component | Key | Purpose | | :------------ | :--------------------- | :--------------------------------------------- | | Storage Vault | `app_user_id` | Persistent identity across sessions/reinstalls | | Backend DB | `app_user_id` | Central user record | | RevenueCat | `external_id` | Links purchases to user | | OneSignal | `external_user_id` | Links push notifications to user | | Stripe | `metadata.app_user_id` | Links payments to user | | Any SDK | varies | Always map back to `app_user_id` | The `app_user_id` is the single source of truth. Every service integration should either accept it directly or map their internal IDs back to it via your backend. # Animations Source: https://setup.despia.com/best-practices/frontend/animations CSS animation first. Scroll-driven CSS for drawers, sheets, parallax, and page transitions. JS fallback only where CSS is not supported. ## Philosophy CSS animations run on the compositor thread. They stay smooth under Low Power Mode, thermal throttling, and every other constraint a mobile device can throw at them. JavaScript animations run on the main thread and compete with everything else the app is doing. The hierarchy is: 1. **CSS scroll-driven** (`animation-timeline`) for anything swipe-driven - drawers, sheets, parallax, page stacks, carousels. Zero JS per frame. Compositor-only. 2. **CSS transitions and keyframes** for everything else - tap feedback, modals, toasts, loaders. Still compositor-safe on `transform` and `opacity`. 3. **JS `requestAnimationFrame`** only when CSS cannot express the result, and only as a fallback when scroll-driven is not supported. Only animate `transform` and `opacity`. Everything else triggers layout or paint on the main thread. *** ## Touch Interaction Feedback The input is always touch. Use `:active`, never `:hover`. `:active` fires on press and clears on release. `:hover` fires on tap and sticks with nothing to clear it. ```css theme={null} .button { transition: transform 0.1s ease, opacity 0.1s ease; } .button:active { transform: scale(0.95); opacity: 0.8; } .card { transition: transform 0.1s ease, opacity 0.1s ease; } .card:active { transform: scale(0.98); opacity: 0.85; } ``` If a component also renders in a browser, guard `:hover` with an input capability query. Screen width tells you nothing about input type. ```css theme={null} @media (hover: hover) and (pointer: fine) { .card:hover { transform: translateY(-0.25rem); } } ``` *** ## CSS Transitions ```css theme={null} /* Tap feedback */ .button { transition: transform 0.1s ease; } /* State changes */ .card { transition: transform 0.3s ease; } /* Large entrances */ .modal { transition: opacity 0.4s ease, transform 0.4s ease; } ``` | Use case | Duration | | :-------------------- | :------------ | | Tap feedback | 0.1s to 0.15s | | Standard state change | 0.2s to 0.3s | | Large movements | 0.3s to 0.5s | | Maximum | 0.6s | ```css theme={null} .enter { transition-timing-function: cubic-bezier(0.0, 0.0, 0.2, 1); } .exit { transition-timing-function: cubic-bezier(0.4, 0.0, 1, 1); } .bounce { transition-timing-function: cubic-bezier(0.68, -0.55, 0.265, 1.55); } ``` *** ## CSS Keyframe Animations ```css theme={null} @keyframes fadeIn { from { opacity: 0; transform: translateY(1rem); } to { opacity: 1; transform: translateY(0); } } .element { animation: fadeIn 0.4s ease forwards; will-change: transform, opacity; } ``` ```css theme={null} animation-fill-mode: forwards; /* hold end state */ animation-fill-mode: both; /* hold start before delay, end after */ ``` *** ## Will-Change Add `will-change: transform` to `position: fixed` or `position: sticky` elements before they animate. Remove it after. ```css theme={null} .drawer { will-change: transform; } ``` ```js theme={null} element.addEventListener('animationend', () => { element.style.willChange = 'auto'; }, { once: true }); ``` Never apply `will-change` globally. Each declaration allocates a compositor layer. *** ## Scroll-Driven Animations For any swipe-driven UI, scroll-driven CSS is the preferred approach. The scroll position drives the animation directly on the compositor. No JS per frame, no touch event listeners, smooth under every power state. ### WebView Compatibility | API | Available | | :--------------------------------- | :------------------------- | | CSS `animation-timeline: scroll()` | Yes | | CSS `animation-timeline: view()` | Yes | | `new ScrollTimeline()` in JS | No - throws ReferenceError | | `new ViewTimeline()` in JS | No - throws ReferenceError | ### Browser Support and Fallbacks `animation-timeline` requires Chromium 115+ / Safari 18+. Use `@supports` to layer it on top of a working CSS fallback. **Write the fallback first, enhance inside `@supports`:** ```css theme={null} /* Fallback - works everywhere */ .drawer { transform: translateX(-100%); transition: transform 0.3s cubic-bezier(0.0, 0.0, 0.2, 1); } .drawer.is-open { transform: translateX(0); } /* Enhancement - scroll-driven where supported */ @supports (animation-timeline: scroll()) { .drawer { transition: none; animation: drawer-in linear both; animation-timeline: scroll(nearest inline); animation-range: 0% 50%; } } ``` **Always start items visible for scroll-reveal.** If `animation-timeline: view()` is unsupported, items stay at `opacity: 0` forever. ```css theme={null} /* Baseline - visible */ .list-item { opacity: 1; transform: translateY(0); } /* Enhancement - animate in */ @supports (animation-timeline: scroll()) { .list-item { opacity: 0; transform: translateY(1rem); animation: fadeInUp 0.4s ease forwards; animation-timeline: view(); animation-range: entry 0% entry 35%; } } @keyframes fadeInUp { to { opacity: 1; transform: translateY(0); } } ``` **Branch in JS with `CSS.supports()`:** ```js theme={null} const supportsScrollTimeline = CSS.supports('animation-timeline: scroll()'); function initDrawer(sceneEl, drawerEl) { if (supportsScrollTimeline) { initScrollDriver(sceneEl, drawerEl); } else { initClassToggle(drawerEl); } } function initScrollDriver(sceneEl, drawerEl) { const driver = sceneEl.querySelector('.scroll-driver'); const openPos = driver.scrollWidth / 2; sceneEl.querySelector('#menuBtn').addEventListener('click', () => driver.scrollTo({ left: openPos, behavior: 'smooth' })); sceneEl.querySelector('#scrim').addEventListener('click', () => driver.scrollTo({ left: 0, behavior: 'smooth' })); onScrollSettled(driver, () => { const open = driver.scrollLeft >= openPos * 0.9; drawerEl.classList.toggle('is-open', open); }); } function initClassToggle(drawerEl) { document.getElementById('menuBtn').addEventListener('click', () => drawerEl.classList.add('is-open')); document.getElementById('scrim').addEventListener('click', () => drawerEl.classList.remove('is-open')); } ``` **Support matrix:** | Environment | `animation-timeline` support | | :---------------------------- | :---------------------------- | | WKWebView - iOS 18+ | Yes | | WKWebView - iOS 17 and below | No - use `@supports` fallback | | Android WebView - Chrome 115+ | Yes | | Android WebView - older | No - use `@supports` fallback | ### The Hidden Scroller Pattern The base technique for all swipe-driven UI. An invisible scroll container captures gestures. Its scroll position becomes the CSS timeline for the content below. ``` User swipes > invisible scroller moves > CSS timeline drives your UI ``` No `touchstart`. No `touchmove`. No `requestAnimationFrame`. Native scroll engine handles physics, momentum, and snap. ```html theme={null}
...
``` ```css theme={null} .scene { position: relative; width: 100%; height: 100%; overflow: hidden; } .scroll-driver { position: absolute; inset: 0; overflow-x: scroll; overflow-y: hidden; scrollbar-width: none; z-index: 20; -webkit-overflow-scrolling: touch; } .scroll-driver::-webkit-scrollbar { display: none; } .scroll-driver-inner { width: 200%; height: 1px; } ``` ```css theme={null} .animated-panel { animation: enter linear both; animation-timeline: scroll(nearest inline); animation-range: 0% 50%; will-change: transform; } @keyframes enter { from { transform: translateX(-100%); } to { transform: translateX(0); } } ``` ### Programmatic Open and Close ```js theme={null} const driver = document.getElementById('driver'); const openPos = driver.scrollWidth / 2; function open() { driver.scrollTo({ left: openPos, behavior: 'smooth' }); } function close() { driver.scrollTo({ left: 0, behavior: 'smooth' }); } ``` ### scrollend `scrollend` fires once when scrolling settles. Update state here, not on every pixel. ```js theme={null} driver.addEventListener('scrollend', () => { const isOpen = driver.scrollLeft >= openPos * 0.9; document.body.dataset.drawer = isOpen ? 'open' : 'closed'; }); ``` Fallback for iOS 16.3 and below: ```js theme={null} function onScrollSettled(el, callback, delay = 100) { if ('onscrollend' in el) { el.addEventListener('scrollend', callback); } else { let t; el.addEventListener('scroll', () => { clearTimeout(t); t = setTimeout(callback, delay); }, { passive: true }); } } ``` *** ## Scroll-Driven Patterns ### Side Drawer ```html theme={null}
``` ```css theme={null} /* Light mode tokens */ :root { --drawer-bg: #ffffff; --drawer-text: #111111; --drawer-border: rgba(0, 0, 0, 0.08); --drawer-scrim: rgba(0, 0, 0, 0.45); } @media (prefers-color-scheme: dark) { :root { --drawer-bg: #1c1c1e; --drawer-text: #f2f2f7; --drawer-border: rgba(255, 255, 255, 0.1); --drawer-scrim: rgba(0, 0, 0, 0.65); } } .drawer-scene { position: relative; width: 100%; height: 100%; overflow: hidden; } .drawer { position: fixed; top: 0; left: 0; bottom: 0; width: 80vw; max-width: 320px; background: var(--drawer-bg); color: var(--drawer-text); border-right: 1px solid var(--drawer-border); will-change: transform; animation: drawer-in linear both; animation-timeline: scroll(nearest inline); animation-range: 0% 50%; } @keyframes drawer-in { from { transform: translateX(-100%); } to { transform: translateX(0); } } .drawer-inner { padding-top: var(--safe-area-top); height: 100%; overflow-y: auto; } .drawer-scrim { position: fixed; inset: 0; background: var(--drawer-scrim); opacity: 0; pointer-events: none; animation: scrim-in linear both; animation-timeline: scroll(nearest inline); animation-range: 0% 50%; } @keyframes scrim-in { from { opacity: 0; } to { opacity: 1; } } ``` ```js theme={null} const driver = document.getElementById('drawerDriver'); const openPos = driver.scrollWidth / 2; document.getElementById('menuBtn').addEventListener('click', () => driver.scrollTo({ left: openPos, behavior: 'smooth' })); document.getElementById('scrim').addEventListener('click', () => driver.scrollTo({ left: 0, behavior: 'smooth' })); driver.addEventListener('scrollend', () => { const open = driver.scrollLeft >= openPos * 0.9; driver.setAttribute('aria-expanded', open); document.body.classList.toggle('nav-open', open); }); ``` ### Bottom Sheet (Gesture-Driven) ```css theme={null} .sheet-driver { position: absolute; inset: 0; overflow-y: scroll; overflow-x: hidden; scrollbar-width: none; z-index: 20; -webkit-overflow-scrolling: touch; } .sheet-driver-inner { height: 200%; width: 1px; } .bottom-sheet { position: fixed; bottom: 0; left: 0; right: 0; height: 60vh; border-radius: 16px 16px 0 0; background: var(--surface); will-change: transform; animation: sheet-up linear both; animation-timeline: scroll(nearest block); animation-range: 0% 50%; } @keyframes sheet-up { from { transform: translateY(100%); } to { transform: translateY(0); } } ``` ```js theme={null} const driver = document.getElementById('sheetDriver'); const openPos = driver.scrollHeight / 2; function openSheet() { driver.scrollTo({ top: openPos, behavior: 'smooth' }); } function closeSheet() { driver.scrollTo({ top: 0, behavior: 'smooth' }); } onScrollSettled(driver, () => { const open = driver.scrollTop >= openPos * 0.9; document.body.dataset.sheet = open ? 'open' : 'closed'; }); ``` ### Detent Sheet (Multiple Snap Points) A sheet that snaps to peek, half, and full open. ```css theme={null} .detent-driver { position: absolute; inset: 0; overflow-y: scroll; overflow-x: hidden; scrollbar-width: none; scroll-snap-type: y mandatory; z-index: 30; -webkit-overflow-scrolling: touch; } .detent-driver::-webkit-scrollbar { display: none; } .detent-driver-inner { height: 300%; width: 1px; } .detent-sheet { position: fixed; bottom: 0; left: 0; right: 0; height: 90vh; background: var(--surface); border-radius: 12px 12px 0 0; will-change: transform; animation: detent-rise linear both; animation-timeline: scroll(nearest block); animation-range: 0% 100%; } @keyframes detent-rise { 0% { transform: translateY(calc(90vh - 3rem)); } 33% { transform: translateY(60%); } 100% { transform: translateY(0); } } .detent-handle { width: 2.5rem; height: 4px; background: var(--border); border-radius: 2px; margin: 0.75rem auto; } ``` ```js theme={null} const driver = document.getElementById('detentDriver'); const PEEK = driver.scrollHeight * 0.33; const OPEN = driver.scrollHeight; function openPeek() { driver.scrollTo({ top: PEEK, behavior: 'smooth' }); } function openFull() { driver.scrollTo({ top: OPEN, behavior: 'smooth' }); } function close() { driver.scrollTo({ top: 0, behavior: 'smooth' }); } ``` ### Full-Screen Page Swipe Stack Full-screen vertical snap-scroll. Each page scales up as it enters and scales down as it exits. ```css theme={null} .page-stack { height: 100dvh; overflow-y: scroll; scroll-snap-type: y mandatory; scrollbar-width: none; overscroll-behavior: contain; } .page-stack::-webkit-scrollbar { display: none; } .stack-page { height: 100dvh; scroll-snap-align: start; scroll-snap-stop: always; will-change: transform, opacity; animation: stack-page-anim linear both; animation-timeline: view(block); animation-range: cover 0% cover 100%; } @keyframes stack-page-anim { 0% { transform: scale(0.94) translateY(4%); opacity: 0.5; } 30% { transform: scale(1) translateY(0); opacity: 1; } 70% { transform: scale(1) translateY(0); opacity: 1; } 100% { transform: scale(0.92) translateY(-2%); opacity: 0.4; } } ``` ```js theme={null} function goToPage(index) { document.querySelectorAll('.stack-page')[index] .scrollIntoView({ behavior: 'smooth', block: 'start' }); } ``` ### Parallax Hero with Fading Title ```css theme={null} .hero { position: relative; height: 60vh; overflow: hidden; } .hero-bg { position: absolute; inset: -20%; background: url('hero.jpg') center / cover no-repeat; will-change: transform; animation: hero-parallax linear both; animation-timeline: scroll(root block); animation-range: 0vh 60vh; } @keyframes hero-parallax { from { transform: translateY(0); } to { transform: translateY(25%); } } .hero-title { will-change: transform, opacity; animation: hero-title-out linear both; animation-timeline: scroll(root block); animation-range: 0vh 40vh; } @keyframes hero-title-out { from { opacity: 1; transform: translateY(0); } to { opacity: 0; transform: translateY(-2rem); } } ``` ### Large Title Navigation Bar Bar title and border fade in as the large title scrolls away. ```css theme={null} .nav-bar { position: sticky; top: 0; height: 44px; background: var(--surface); border-bottom: 1px solid transparent; z-index: 10; } .nav-bar-title { opacity: 0; animation: bar-title-in linear both; animation-timeline: scroll(nearest block); animation-range: 40px 80px; } @keyframes bar-title-in { from { opacity: 0; } to { opacity: 1; } } .nav-bar { animation: bar-border-in linear both; animation-timeline: scroll(nearest block); animation-range: 40px 80px; } @keyframes bar-border-in { from { border-color: transparent; } to { border-color: var(--border); } } .nav-large-title { font-size: 2rem; font-weight: 700; will-change: transform, opacity; animation: large-title-out linear both; animation-timeline: scroll(nearest block); animation-range: 0px 70px; } @keyframes large-title-out { from { opacity: 1; transform: translateY(0) scale(1); } to { opacity: 0; transform: translateY(-0.5rem) scale(0.85); } } ``` ### Swipe Card Stack ```css theme={null} .card-stack { display: flex; overflow-x: scroll; scroll-snap-type: x mandatory; scrollbar-width: none; } .card-stack::-webkit-scrollbar { display: none; } .card { flex: 0 0 100%; scroll-snap-align: center; will-change: transform, opacity; animation: card-anim linear both; animation-timeline: view(inline); animation-range: cover 0% cover 100%; } @keyframes card-anim { 0% { transform: scale(0.88) translateX(12%); opacity: 0.4; } 33% { transform: scale(1) translateX(0); opacity: 1; } 66% { transform: scale(1) translateX(0); opacity: 1; } 100% { transform: scale(0.88) translateX(-12%); opacity: 0.4; } } ``` ### Scroll-Reveal List ```css theme={null} /* Baseline - visible on unsupported browsers */ .list-item { opacity: 1; transform: translateY(0); } @supports (animation-timeline: scroll()) { .list-item { opacity: 0; transform: translateY(1rem); animation: fadeInUp 0.4s ease forwards; animation-timeline: view(); animation-range: entry 0% entry 35%; } .list-item:nth-child(1) { animation-delay: 0s; } .list-item:nth-child(2) { animation-delay: 0.1s; } .list-item:nth-child(3) { animation-delay: 0.2s; } .list-item:nth-child(4) { animation-delay: 0.3s; } } @keyframes fadeInUp { to { opacity: 1; transform: translateY(0); } } ``` ### Strip Viewer Horizontal snap viewer for comics, onboarding, or photo strips. Progress bar requires no JS. ```html theme={null}
Page 1
Page 2
1 / 12
``` ```css theme={null} .strip { display: flex; overflow-x: scroll; scroll-snap-type: x mandatory; scrollbar-width: none; width: 100%; height: 100%; } .strip::-webkit-scrollbar { display: none; } .strip-page { flex: 0 0 100%; scroll-snap-align: center; animation: page-focus linear both; animation-timeline: view(inline); animation-range: cover 0% cover 100%; } @keyframes page-focus { 0% { opacity: 0.2; transform: scale(0.92); } 33% { opacity: 1; transform: scale(1); } 66% { opacity: 1; transform: scale(1); } 100% { opacity: 0.2; transform: scale(0.92); } } .strip-page img { max-width: 100%; max-height: 100%; object-fit: contain; pointer-events: none; user-select: none; } .strip-progress { position: fixed; bottom: 0; left: 0; height: 2px; width: 100%; background: var(--accent); transform-origin: left; animation: progress linear both; animation-timeline: scroll(nearest inline); } @keyframes progress { from { transform: scaleX(0); } to { transform: scaleX(1); } } ``` ```js theme={null} const strip = document.getElementById('strip'); const total = strip.querySelectorAll('.strip-page').length; onScrollSettled(strip, () => { const current = Math.round(strip.scrollLeft / strip.offsetWidth) + 1; document.getElementById('counter').textContent = `${current} / ${total}`; }); function goToPage(n) { strip.scrollTo({ left: (n - 1) * strip.offsetWidth, behavior: 'smooth' }); } ``` ### Carousel with Indicator Dots ```css theme={null} .carousel { display: flex; overflow-x: scroll; scroll-snap-type: x mandatory; scrollbar-width: none; } .carousel::-webkit-scrollbar { display: none; } .carousel-panel { flex: 0 0 100%; scroll-snap-align: start; } .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--text-faint); border: none; transition: background 0.2s, transform 0.2s; } .dot.active { background: var(--accent); transform: scale(1.3); } ``` ```js theme={null} const carousel = document.getElementById('carousel'); const dots = document.querySelectorAll('.dot'); dots.forEach(dot => { dot.addEventListener('click', () => carousel.scrollTo({ left: parseInt(dot.dataset.index) * carousel.offsetWidth, behavior: 'smooth' }) ); }); onScrollSettled(carousel, () => { const i = Math.round(carousel.scrollLeft / carousel.offsetWidth); dots.forEach((d, j) => d.classList.toggle('active', i === j)); }); ``` ### Context Menu Entrance Scales up from the tap point. Removes itself from DOM on dismiss. ```css theme={null} .context-menu { position: fixed; background: var(--surface); border-radius: 14px; min-width: 200px; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18); transform-origin: var(--origin-x, 50%) var(--origin-y, 0%); animation: context-in 0.22s cubic-bezier(0.34, 1.36, 0.64, 1) forwards; will-change: transform, opacity; } @keyframes context-in { from { transform: scale(0.7); opacity: 0; } to { transform: scale(1); opacity: 1; } } .context-menu.is-dismissing { animation: context-out 0.16s ease-in forwards; } @keyframes context-out { from { transform: scale(1); opacity: 1; } to { transform: scale(0.85); opacity: 0; } } .context-item { display: block; width: 100%; padding: 0.75rem 1rem; background: none; border: none; font-size: 1rem; color: var(--text); text-align: left; transition: background 0.1s ease; } .context-item:active { background: var(--surface2); } .context-item--destructive { color: #ef4444; } ``` ```js theme={null} function showContextMenu(x, y) { const menu = document.getElementById('contextMenu'); const vw = window.innerWidth, vh = window.innerHeight; menu.style.left = `${x}px`; menu.style.top = `${y}px`; menu.style.setProperty('--origin-x', `${(x / vw * 100).toFixed(1)}%`); menu.style.setProperty('--origin-y', `${(y / vh * 100).toFixed(1)}%`); document.body.appendChild(menu); void menu.offsetHeight; menu.classList.remove('is-dismissing'); } function dismissContextMenu() { const menu = document.getElementById('contextMenu'); if (!menu) return; menu.classList.add('is-dismissing'); menu.addEventListener('animationend', () => menu.remove(), { once: true }); } document.addEventListener('click', (e) => { const menu = document.getElementById('contextMenu'); if (menu && !menu.contains(e.target)) dismissContextMenu(); }); ``` ### Tab Bar with Sliding Indicator ```css theme={null} .tab-bar { position: fixed; bottom: 0; left: 0; right: 0; height: calc(56px + var(--safe-area-bottom)); padding-bottom: var(--safe-area-bottom); display: flex; align-items: stretch; background: var(--surface); border-top: 1px solid var(--border); } .tab { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; background: none; border: none; transition: opacity 0.1s ease; } .tab:active { opacity: 0.6; } .tab-label { font-size: 0.625rem; color: var(--text-faint); } .tab[aria-selected="true"] .tab-label { color: var(--accent); } .tab-indicator { position: absolute; top: 6px; width: 44px; height: 30px; background: var(--accent-subtle, rgba(0, 122, 255, 0.12)); border-radius: 10px; will-change: transform; transition: transform 0.3s cubic-bezier(0.34, 1.2, 0.64, 1); } ``` ```js theme={null} const tabBar = document.getElementById('tabBar'); const indicator = document.getElementById('tabIndicator'); const tabs = tabBar.querySelectorAll('.tab'); function updateIndicator(tab) { const barRect = tabBar.getBoundingClientRect(); const tabRect = tab.getBoundingClientRect(); const centerX = tabRect.left + tabRect.width / 2 - barRect.left; indicator.style.transform = `translateX(${centerX - 22}px)`; } updateIndicator(tabs[0]); tabs.forEach(tab => { tab.addEventListener('click', () => { tabs.forEach(t => t.setAttribute('aria-selected', 'false')); tab.setAttribute('aria-selected', 'true'); updateIndicator(tab); }); }); ``` ### Notification Banner Slides down from the top, auto-dismisses, removes from DOM on exit. ```css theme={null} .alert-banner { position: fixed; top: 0; left: 1rem; right: 1rem; margin-top: var(--safe-area-top); background: var(--surface); border-radius: 14px; box-shadow: 0 4px 24px rgba(0, 0, 0, 0.14); display: flex; align-items: center; gap: 0.75rem; padding: 0.875rem 1rem; z-index: 100; will-change: transform, opacity; transform: translateY(calc(-100% - var(--safe-area-top) - 1rem)); } .alert-banner.is-entering { animation: banner-in 0.45s cubic-bezier(0.34, 1.2, 0.64, 1) forwards; } .alert-banner.is-exiting { animation: banner-out 0.3s cubic-bezier(0.4, 0.0, 1, 1) forwards; } @keyframes banner-in { from { transform: translateY(calc(-100% - var(--safe-area-top) - 1rem)); opacity: 0.6; } to { transform: translateY(0.5rem); opacity: 1; } } @keyframes banner-out { from { transform: translateY(0.5rem); opacity: 1; } to { transform: translateY(calc(-100% - var(--safe-area-top) - 1rem)); opacity: 0; } } ``` ```js theme={null} function showBanner(durationMs = 4000) { const banner = document.getElementById('alertBanner'); document.body.appendChild(banner); void banner.offsetHeight; banner.classList.add('is-entering'); const t = setTimeout(() => dismissBanner(), durationMs); banner.addEventListener('click', () => { clearTimeout(t); dismissBanner(); }); } function dismissBanner() { const banner = document.getElementById('alertBanner'); if (!banner) return; banner.classList.remove('is-entering'); banner.classList.add('is-exiting'); banner.addEventListener('animationend', () => banner.remove(), { once: true }); } ``` *** ## DOM Lifecycle Keep off-screen elements out of the DOM. Remove them after exit animations. Animate them in after inserting. ### Remove After Exit ```js theme={null} function dismiss(el) { el.classList.add('is-exiting'); el.addEventListener('transitionend', () => el.remove(), { once: true }); } ``` ### Animate In After Insert Inserting and immediately adding the active class in the same tick skips the transition. Force a reflow between the two. ```js theme={null} function show(el) { document.body.appendChild(el); void el.offsetHeight; // flush styles el.classList.add('is-open'); } ``` ### Full Lifecycle ```js theme={null} function showToast(message) { const toast = document.createElement('div'); toast.className = 'toast'; toast.textContent = message; document.body.appendChild(toast); void toast.offsetHeight; toast.classList.add('is-visible'); setTimeout(() => { toast.classList.remove('is-visible'); toast.classList.add('is-exiting'); toast.addEventListener('transitionend', () => toast.remove(), { once: true }); }, 3000); } ``` *** ## Modal and Sheet Animations ### Fade and Scale ```css theme={null} .modal-overlay { opacity: 0; transition: opacity 0.3s ease; } .modal-overlay.open { opacity: 1; } .modal-content { opacity: 0; transform: scale(0.9) translateY(-1rem); transition: opacity 0.3s ease, transform 0.3s ease; } .modal-content.open { opacity: 1; transform: scale(1) translateY(0); } ``` ### Slide from Bottom (Class-Toggled) For sheets that do not need gesture tracking. Use the Hidden Scroller Pattern when gesture progress matters. ```css theme={null} .sheet { transform: translateY(100%); transition: transform 0.3s cubic-bezier(0.0, 0.0, 0.2, 1); } .sheet.open { transform: translateY(0); } ``` *** ## Page Transitions ```css theme={null} .page { position: absolute; width: 100%; transform: translateX(100%); transition: transform 0.3s cubic-bezier(0.0, 0.0, 0.2, 1); } .page.active { transform: translateX(0); } .page.exiting { transform: translateX(-100%); } ``` *** ## Common Patterns ### Button Tap Feedback ```css theme={null} .button { transition: transform 0.1s ease; } .button:active { transform: scale(0.95); } ``` ### Toggle Switch ```css theme={null} .toggle { width: 3rem; height: 1.5rem; background: #ccc; border-radius: 1.5rem; position: relative; transition: background 0.3s ease; } .toggle.active { background: #007bff; } .toggle-handle { width: 1.25rem; height: 1.25rem; background: white; border-radius: 50%; position: absolute; top: 0.125rem; left: 0.125rem; transition: transform 0.3s cubic-bezier(0.4, 0.0, 0.2, 1); } .toggle.active .toggle-handle { transform: translateX(1.5rem); } ``` ### Loading Skeleton ```css theme={null} .skeleton { background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); background-size: 200% 100%; animation: skeleton-sweep 1.5s ease-in-out infinite; } @keyframes skeleton-sweep { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } } ``` ### Spinner ```css theme={null} .spinner { width: 2.5rem; height: 2.5rem; border: 0.25rem solid #f0f0f0; border-top-color: #007bff; border-radius: 50%; animation: spin 1s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } ``` *** ## JS Fallback Animations Use `requestAnimationFrame` only when CSS cannot express the result - physics simulations, canvas, procedural effects - or as a fallback for `animation-timeline` on unsupported browsers. ```js theme={null} function animate(element, from, to, duration) { const start = performance.now(); function step(now) { const progress = Math.min((now - start) / duration, 1); const eased = 1 - Math.pow(1 - progress, 3); // ease-out cubic element.style.transform = `translateX(${from + (to - from) * eased}px)`; if (progress < 1) requestAnimationFrame(step); } requestAnimationFrame(step); } ``` Read all layout values before writing. Interleaved reads and writes force repeated reflows. ```js theme={null} // Wrong elements.forEach(el => { el.style.transform = `translateX(${el.offsetWidth * 0.5}px)`; }); // Correct const offsets = elements.map(el => el.offsetWidth * 0.5); requestAnimationFrame(() => { elements.forEach((el, i) => { el.style.transform = `translateX(${offsets[i]}px)`; }); }); ``` *** ## Accessibility ```css theme={null} @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; scroll-behavior: auto !important; } } ``` Wrap scroll-driven keyframes so items do not get stuck invisible on reduced motion: ```css theme={null} @media (prefers-reduced-motion: no-preference) { .drawer { animation: drawer-in linear both; animation-timeline: scroll(nearest inline); animation-range: 0% 50%; } @keyframes drawer-in { from { transform: translateX(-100%); } to { transform: translateX(0); } } } .drawer { transform: translateX(-100%); transition: none; } .drawer.is-open { transform: translateX(0); } ``` *** ## Platform Notes **iOS (WKWebView)** * JS animations throttle to \~30fps under Low Power Mode * CSS scroll-driven animations are not throttled by Low Power Mode * `transform` and `opacity` stay smooth across all power states * Avoid animating `box-shadow` - triggers paint every frame **Android (WebView)** * Test on mid-range hardware (4GB RAM) * Be conservative with simultaneous animations *** ## Quick Reference ``` Always safe: transform, opacity Never animate: width, height, top, left, margin, padding Avoid in scroll-driven: box-shadow, filter, backdrop-filter ``` **Checklist** * CSS scroll-driven first for drawers, sheets, parallax, page stacks * `@supports (animation-timeline: scroll())` fallback for all scroll-driven CSS * Start scroll-reveal items visible so they do not get stuck on unsupported browsers * `:active` for touch feedback, never `:hover` * `will-change` only on actively animating elements, remove after * `{ passive: true }` on all scroll listeners * State updates in `scrollend`, not per pixel * Remove elements from DOM after exit animations * `void el.offsetHeight` between insert and animate-in * Wrap scroll-driven keyframes in `prefers-reduced-motion: no-preference` * Test on mid-range Android and under iOS Low Power Mode # Performance Source: https://setup.despia.com/best-practices/frontend/performance Build applications optimized for 60fps performance that degrade gracefully under system constraints. This guide covers proven techniques for JavaScript execution, rendering optimization, memory management, and asset delivery. ## Overview Despia applications run on our GPU-accelerated native webview engine. With proper optimization, applications can achieve 60fps performance. While the Despia runtime handles core rendering optimizations, application performance depends on code structure, asset management, and efficient rendering strategies. **Observed Performance:** * **Normal Operation**: 60fps achievable with optimization * **Low Power Mode**: Typically \~30fps (based on extensive testing across devices) **Important:** iOS aims to render at display refresh rate. Lower frame rates occur when applications cannot meet frame budgets under system constraints (Low Power Mode, thermal throttling, heavy workload). This guide provides actionable patterns for building applications optimized for 60fps that degrade gracefully under constrained conditions. *** ## Low Power Mode Considerations ### Understanding Low Power Mode Low Power Mode reduces background activity and caps ProMotion displays to 60Hz. It does not enforce a specific frame rate, but in practice, rendering performance is often affected. **Technical Note:** iOS aims to render at display refresh rate (60Hz on standard displays, up to 120Hz on ProMotion). Low Power Mode does not set a "30fps limit." However, based on extensive testing across devices, apps commonly render at approximately 30fps when Low Power Mode is active due to reduced performance headroom. **Normal Operation:** * 60fps achievable performance * Full GPU acceleration * Smooth animations and interactions **Low Power Mode (Observed Behavior):** * Apps typically render at \~30fps under Low Power Mode constraints * This occurs because reduced CPU/GPU headroom makes it difficult to meet 16.6ms frame budget * Still provides acceptable user experience for standard interactions **Why Frame Rates Drop:** iOS always attempts to render at display refresh rate. Under Low Power Mode: * Reduced CPU/GPU performance allocation * Background work throttling * Thermal management constraints * Apps struggling to meet frame budget drop frames, often settling around 30fps **Optimization Strategy:** Build for 60fps in normal operation. When properly optimized, applications maintain acceptable performance even under Low Power Mode constraints. Focus on: * Efficient JavaScript execution * GPU-accelerated animations * Minimal layout recalculations * Optimized asset loading ### What Requires Optimization Well-optimized interactions remain smooth even when frame rates drop: * Page transitions * Button taps and feedback * Modal animations * Content fades * List scrolling with momentum * Form interactions **Performance-critical scenarios:** * Real-time games * Continuous drag-and-drop with complex visuals * High-speed animations with many elements For standard applications (AI wrappers, consumer apps, business apps, community platforms, e-commerce, content apps), proper optimization ensures smooth performance across normal operation and constrained conditions (Low Power Mode, thermal throttling). *** ## JavaScript Performance ### Minimize Main Thread Blocking Long-running JavaScript blocks the main thread and causes frame drops. Break up heavy computations: ```javascript theme={null} // Incorrect: blocks main thread function processLargeDataset(data) { const results = []; for (let i = 0; i < data.length; i++) { results.push(expensiveOperation(data[i])); } return results; } // Correct: chunked processing async function processLargeDataset(data) { const results = []; const chunkSize = 100; for (let i = 0; i < data.length; i += chunkSize) { const chunk = data.slice(i, i + chunkSize); await new Promise(resolve => { requestAnimationFrame(() => { chunk.forEach(item => results.push(expensiveOperation(item))); resolve(); }); }); } return results; } ``` ### Debounce High-Frequency Events Scroll, resize, and input events fire rapidly. Limit execution frequency: ```javascript theme={null} function debounce(func, wait) { let timeout; return function executedFunction(...args) { clearTimeout(timeout); timeout = setTimeout(() => func.apply(this, args), wait); }; } // Apply to event handlers const handleScroll = debounce(() => { updateScrollPosition(); }, 16); // ~60fps baseline window.addEventListener('scroll', handleScroll, { passive: true }); ``` ### Use Passive Event Listeners Mark event listeners as passive when they don't call `preventDefault()`: ```javascript theme={null} // Improves scroll performance element.addEventListener('touchstart', handleTouch, { passive: true }); element.addEventListener('wheel', handleWheel, { passive: true }); ``` ### Avoid Memory Leaks Remove event listeners and clear timers when components unmount: ```javascript theme={null} class Component { constructor() { this.handleResize = this.handleResize.bind(this); this.intervalId = null; } mount() { window.addEventListener('resize', this.handleResize); this.intervalId = setInterval(this.update, 1000); } unmount() { window.removeEventListener('resize', this.handleResize); clearInterval(this.intervalId); this.intervalId = null; } } ``` *** ## CSS Performance ### Minimize Layout Thrashing Batch DOM reads and writes separately: ```javascript theme={null} // Incorrect: causes multiple reflows function updateElements(elements) { elements.forEach(el => { const height = el.offsetHeight; // Read el.style.height = height + 10 + 'px'; // Write }); } // Correct: batch reads, then writes function updateElements(elements) { const heights = elements.map(el => el.offsetHeight); // Batch reads elements.forEach((el, i) => { el.style.height = heights[i] + 10 + 'px'; // Batch writes }); } ``` ### Use Transform and Opacity for Animations Transform and opacity properties are GPU-accelerated and don't trigger layout: ```css theme={null} /* Incorrect: triggers layout */ .animated { transition: width 0.3s, height 0.3s, top 0.3s, left 0.3s; } /* Correct: GPU-accelerated */ .animated { transition: transform 0.3s, opacity 0.3s; will-change: transform, opacity; } ``` **Note:** GPU-accelerated animations maintain 60fps smoothly. Transform and opacity changes are handled by the GPU, keeping the main thread free for other work. ### Limit will-change Usage `will-change` creates composite layers. Use sparingly and remove after animation: ```javascript theme={null} element.addEventListener('mouseenter', () => { element.style.willChange = 'transform'; }); element.addEventListener('mouseleave', () => { element.style.willChange = 'auto'; }); ``` ### Reduce Paint Complexity Complex box shadows and gradients are expensive. Prefer simple styles: ```css theme={null} /* Expensive */ .card { box-shadow: 0 10px 40px rgba(0,0,0,0.3), 0 5px 20px rgba(0,0,0,0.2), inset 0 1px 0 rgba(255,255,255,0.1); background: linear-gradient(45deg, #667eea 0%, #764ba2 100%); } /* More efficient */ .card { box-shadow: 0 4px 12px rgba(0,0,0,0.15); background: #667eea; } ``` ### Use contain Property CSS containment optimizes rendering by limiting layout/paint scope: ```css theme={null} .card { contain: layout style paint; } .list-item { contain: layout style; } /* Strict containment for isolated components */ .widget { contain: strict; } ``` *** ## Image Optimization ### Use Appropriate Image Formats * **JPEG**: Photos and complex images * **PNG**: Images requiring transparency * **WebP**: Modern format with better compression (provide fallbacks) * **SVG**: Icons and simple graphics ```html theme={null} Description ``` ### Implement Lazy Loading Defer offscreen images: ```html theme={null} Description ``` ```javascript theme={null} // Intersection Observer for custom lazy loading const imageObserver = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { const img = entry.target; img.src = img.dataset.src; imageObserver.unobserve(img); } }); }); document.querySelectorAll('img[data-src]').forEach(img => { imageObserver.observe(img); }); ``` ### Optimize Image Dimensions Serve images at display size, not larger: ```html theme={null} ``` ### Use Responsive Images Serve different sizes based on viewport: ```html theme={null} Description ``` *** ## DOM Manipulation ### Minimize DOM Access Cache DOM queries: ```javascript theme={null} // Incorrect: repeated queries function updateList() { document.querySelector('.list').innerHTML = ''; for (let i = 0; i < items.length; i++) { document.querySelector('.list').appendChild(createItem(items[i])); } } // Correct: cache reference function updateList() { const list = document.querySelector('.list'); list.innerHTML = ''; items.forEach(item => list.appendChild(createItem(item))); } ``` ### Use Document Fragments Batch DOM insertions: ```javascript theme={null} // Incorrect: multiple reflows items.forEach(item => { container.appendChild(createItem(item)); }); // Correct: single reflow const fragment = document.createDocumentFragment(); items.forEach(item => { fragment.appendChild(createItem(item)); }); container.appendChild(fragment); ``` ### Virtual Scrolling for Long Lists For long lists (100+ items), use virtual scrolling to render only visible items. **TanStack Virtual** is proven to work exceptionally well with Despia. **Installation:** ``` npm install @tanstack/react-virtual ``` **Basic Implementation:** ```tsx theme={null} import { useVirtualizer } from '@tanstack/react-virtual'; import { useRef } from 'react'; function VirtualList({ items }) { const parentRef = useRef(null); const virtualizer = useVirtualizer({ count: items.length, getScrollElement: () => parentRef.current, estimateSize: () => 50, // Estimated item height overscan: 5 // Render 5 extra items above/below viewport }); return (
{virtualizer.getVirtualItems().map((virtualItem) => (
{items[virtualItem.index].content}
))}
); } ``` **Dynamic Heights:** ```javascript theme={null} const virtualizer = useVirtualizer({ count: items.length, getScrollElement: () => parentRef.current, estimateSize: () => 80, // Measure actual heights dynamically measureElement: typeof window !== 'undefined' && navigator.userAgent.indexOf('Firefox') === -1 ? (element) => element?.getBoundingClientRect().height : undefined }); ``` **Horizontal Scrolling:** ```javascript theme={null} const virtualizer = useVirtualizer({ horizontal: true, count: items.length, getScrollElement: () => parentRef.current, estimateSize: () => 200 }); ``` **Why TanStack Virtual Works Well with Despia:** * Optimized for high frame rates * Efficient DOM updates * Minimal re-renders * Handles dynamic content heights * Works seamlessly with Despia's GPU-accelerated rendering * Supports horizontal and grid layouts **Performance Benefits:** * Large datasets: Renders only visible items (typically 15-25 items) * Reduces memory usage significantly * Maintains smooth scrolling performance * Handles thousands of items efficiently *** ## Memory Management ### Avoid Global Variables Global variables persist for the application lifetime: ```javascript theme={null} // Incorrect: global pollution var dataCache = []; var userInfo = {}; // Correct: scoped to module (function() { const dataCache = []; const userInfo = {}; // Export only necessary functions window.app = { getData: () => dataCache, clearCache: () => dataCache.length = 0 }; })(); ``` ### Clear Large Data Structures Explicitly null large objects when finished: ```javascript theme={null} let largeDataset = fetchLargeData(); processData(largeDataset); largeDataset = null; // Allow garbage collection ``` ### Use WeakMap for Object Metadata WeakMaps allow garbage collection of keys: ```javascript theme={null} // Incorrect: prevents garbage collection const metadata = new Map(); metadata.set(domElement, { clicks: 0 }); // Correct: allows garbage collection const metadata = new WeakMap(); metadata.set(domElement, { clicks: 0 }); // When domElement is removed, metadata entry can be collected ``` *** ## Network Optimization ### Minimize HTTP Requests Combine resources where possible: ```html theme={null} ``` ### Use Resource Hints Optimize resource loading: ```html theme={null} ``` ### Implement Efficient Caching Configure cache headers appropriately: ```javascript theme={null} // Service worker caching strategy self.addEventListener('fetch', (event) => { event.respondWith( caches.match(event.request).then((response) => { if (response) { return response; // Serve from cache } return fetch(event.request).then((response) => { // Cache successful responses if (response.status === 200) { const responseClone = response.clone(); caches.open('v1').then((cache) => { cache.put(event.request, responseClone); }); } return response; }); }) ); }); ``` ### Compress Assets Enable gzip/brotli compression on server. Minify JavaScript and CSS: ```javascript theme={null} // Before minification (5.2kb) function calculateTotal(items) { let total = 0; for (let i = 0; i < items.length; i++) { total += items[i].price * items[i].quantity; } return total; } // After minification (1.8kb) function calculateTotal(t){let l=0;for(let e=0;e ``` ### Subset Fonts Include only required characters: ```css theme={null} /* Full font: 150kb */ @font-face { font-family: 'Font'; src: url('font-full.woff2'); } /* Subset font: 25kb (Latin characters only) */ @font-face { font-family: 'Font'; src: url('font-latin.woff2'); unicode-range: U+0000-00FF, U+0131, U+0152-0153; } ``` *** ## Third-Party Scripts ### Load Scripts Asynchronously Prevent blocking: ```html theme={null} ``` ### Lazy Load Non-Critical Scripts Defer loading until needed: ```javascript theme={null} function loadScript(src) { return new Promise((resolve, reject) => { const script = document.createElement('script'); script.src = src; script.onload = resolve; script.onerror = reject; document.head.appendChild(script); }); } // Load when user interaction requires it button.addEventListener('click', async () => { await loadScript('https://cdn.example.com/library.js'); initializeFeature(); }); ``` ### Monitor Third-Party Impact Use Performance API to measure: ```javascript theme={null} const observer = new PerformanceObserver((list) => { list.getEntries().forEach((entry) => { if (entry.name.includes('third-party.com')) { console.log(`${entry.name}: ${entry.duration}ms`); } }); }); observer.observe({ entryTypes: ['resource'] }); ``` *** ## Bundle Size Optimization ### Code Splitting Load code only when needed: ```javascript theme={null} // Instead of importing everything import { featureA, featureB, featureC } from './features'; // Dynamic imports button.addEventListener('click', async () => { const { featureA } = await import('./features/featureA.js'); featureA(); }); ``` ### Tree Shaking Ensure dead code is eliminated: ```javascript theme={null} // Incorrect: imports entire library import _ from 'lodash'; // Correct: imports only needed function import debounce from 'lodash/debounce'; ``` **Recommended Libraries:** These libraries are proven to work well with Despia and have minimal bundle impact: * **TanStack Virtual** (\~5kb): Virtual scrolling for lists * **date-fns** (tree-shakeable): Date manipulation * **zustand** (\~1kb): State management ### Analyze Bundle Size Identify large dependencies: ```javascript theme={null} # Using webpack-bundle-analyzer npm install --save-dev webpack-bundle-analyzer # Add to webpack config const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; module.exports = { plugins: [ new BundleAnalyzerPlugin() ] }; ``` *** ## Performance Monitoring ### Measure Frame Rate Track rendering performance: ```javascript theme={null} let lastTime = performance.now(); let frames = 0; function measureFPS() { frames++; const currentTime = performance.now(); if (currentTime >= lastTime + 1000) { const fps = Math.round((frames * 1000) / (currentTime - lastTime)); console.log(`FPS: ${fps}`); // Performance evaluation if (fps >= 55) { console.log('Excellent performance (60fps target)'); } else if (fps >= 50) { console.log('Good performance'); } else if (fps >= 28) { console.log('Constrained performance (Low Power Mode or thermal throttling)'); } else { console.warn('Performance optimization needed'); } frames = 0; lastTime = currentTime; } requestAnimationFrame(measureFPS); } measureFPS(); ``` **Typical Results:** * Normal operation: 55-60fps (target) * Low Power Mode: \~30fps (observed across devices) * Heavy workload/thermal: Variable, often 30-45fps * Needs optimization: \< 25fps consistently ### Use Performance API Measure critical operations: ```javascript theme={null} // Mark start performance.mark('data-fetch-start'); // Perform operation await fetchData(); // Mark end performance.mark('data-fetch-end'); // Measure duration performance.measure('data-fetch', 'data-fetch-start', 'data-fetch-end'); // Get measurement const measure = performance.getEntriesByName('data-fetch')[0]; console.log(`Data fetch took ${measure.duration}ms`); ``` ### Long Task Detection Identify main thread blocking: ```javascript theme={null} const observer = new PerformanceObserver((list) => { list.getEntries().forEach((entry) => { console.warn(`Long task detected: ${entry.duration}ms`); }); }); observer.observe({ entryTypes: ['longtask'] }); ``` *** ## Common Performance Mistakes ### Rendering Large Lists Without Virtualization ```tsx theme={null} // Incorrect: renders all items function ItemList({ items }) { return (
{items.map(item => )}
); } // Correct: use TanStack Virtual import { useVirtualizer } from '@tanstack/react-virtual'; function ItemList({ items }) { const parentRef = useRef(null); const virtualizer = useVirtualizer({ count: items.length, getScrollElement: () => parentRef.current, estimateSize: () => 50 }); return (
{/* Virtual items only */}
); } ``` **Impact:** Large lists without virtualization cause significant frame drops and poor scroll performance. TanStack Virtual maintains smooth scrolling by rendering only visible items. ### Excessive Re-renders ```javascript theme={null} // Incorrect: creates new object every render function Component() { return items.map(item => ( )); } // Correct: stable object reference const itemStyle = { color: 'red' }; function Component() { return items.map(item => ( )); } ``` ### Synchronous localStorage ```javascript theme={null} // Incorrect: blocks main thread const data = JSON.parse(localStorage.getItem('data')); // Correct: defer to next tick setTimeout(() => { const data = JSON.parse(localStorage.getItem('data')); processData(data); }, 0); ``` ### Inefficient Selectors ```javascript theme={null} // Incorrect: slow selector document.querySelectorAll('div.container ul li a.link'); // Correct: specific selector document.querySelectorAll('.link'); ``` ### Missing Image Dimensions ```html theme={null} Description Description ``` *** ## Performance Budget ### Define Targets Set measurable performance goals: | Metric | Target | Maximum | | :----------------------- | :------- | :------ | | First Contentful Paint | \< 1.5s | 2.0s | | Time to Interactive | \< 3.0s | 4.0s | | Total Bundle Size | \< 200kb | 300kb | | JavaScript Execution | \< 500ms | 1000ms | | Frame Rate (Normal) | 60fps | 55fps | | Frame Rate (Constrained) | 30fps | 25fps | **Note:** Optimize for 60fps. Proper optimization ensures acceptable performance (\~30fps) under system constraints (Low Power Mode, thermal throttling). ### Monitor Continuously Track metrics in production: ```javascript theme={null} // Send performance data to analytics window.addEventListener('load', () => { const perfData = performance.getEntriesByType('navigation')[0]; analytics.track('performance', { dns: perfData.domainLookupEnd - perfData.domainLookupStart, tcp: perfData.connectEnd - perfData.connectStart, ttfb: perfData.responseStart - perfData.requestStart, download: perfData.responseEnd - perfData.responseStart, domInteractive: perfData.domInteractive, domComplete: perfData.domComplete, loadComplete: perfData.loadEventEnd }); }); ``` *** ## Platform-Specific Considerations ### iOS (WKWebView) * Memory limits are stricter (\< 1.5GB typical) * JIT compilation available but with limits * Aggressive resource cleanup on background * IndexedDB has 50MB quota **Best Practices:** * Keep memory usage under 1GB * Implement state persistence for background handling * Use sessionStorage for temporary data * Monitor memory with performance.memory (when available) ### Android (WebView) * Performance varies significantly by device * Chromium-based (recent Android versions) * Hardware acceleration configurable * More generous memory limits on modern devices **Best Practices:** * Test on low-end devices (2GB RAM) * Enable hardware acceleration * Use chrome://inspect for debugging * Implement graceful degradation for older Android versions *** ## Despia-Optimized Libraries These libraries have been tested extensively with Despia and provide excellent performance: ### TanStack Virtual Virtual scrolling for lists and grids. Handles large datasets efficiently with smooth scrolling performance. ``` npm install @tanstack/react-virtual ``` **Use cases:** * Long lists (100+ items) * Infinite scroll feeds * Data tables * Grid layouts ### TanStack Query Data fetching and caching. Reduces redundant network requests and improves perceived performance. ``` npm install @tanstack/react-query ``` **Benefits:** * Automatic background refetching * Optimistic updates * Request deduplication * Built-in caching ### Date Libraries For date manipulation, use tree-shakeable libraries: ``` npm install date-fns # or npm install dayjs ``` Avoid moment.js (large bundle size). ### State Management Lightweight options that work well: ``` npm install zustand # ~1kb npm install jotai # ~3kb npm install valtio # ~3kb ``` Avoid Redux if unnecessary (larger overhead). *** ## Testing Performance ### Device Testing Test on representative devices: **Minimum targets:** * iOS: iPhone 12 or equivalent * Android: Device with 4GB RAM, mid-range processor **Observed frame rates:** * Normal operation: 55-60fps (target) * Low Power Mode: \~30fps (typical) * Thermal throttling: Variable performance Test for 60fps optimization. Also verify acceptable performance when Low Power Mode is enabled or under thermal constraints. ### Performance Testing Tools ``` // Lighthouse CI for automated testing module.exports = { ci: { collect: { numberOfRuns: 3, settings: { preset: 'desktop' } }, assert: { assertions: { 'first-contentful-paint': ['error', {maxNumericValue: 2000}], 'interactive': ['error', {maxNumericValue: 4000}], 'total-blocking-time': ['error', {maxNumericValue: 500}] } } } }; ``` ### Synthetic Monitoring Simulate network conditions: ```javascript theme={null} // Chrome DevTools throttling presets const networkProfiles = { fast3G: { downloadThroughput: 1.6 * 1024 * 1024 / 8, uploadThroughput: 750 * 1024 / 8, latency: 40 }, slow3G: { downloadThroughput: 500 * 1024 / 8, uploadThroughput: 500 * 1024 / 8, latency: 400 } }; ``` *** ## Quick Reference ### Critical Rendering Path 1. Minimize critical resources 2. Minimize critical bytes 3. Minimize critical path length 4. Prioritize visible content ### Performance Checklist * Bundle size \< 300kb gzipped * Images optimized and lazy loaded * CSS animations use transform/opacity only * Event listeners marked passive where applicable * Large lists use TanStack Virtual for virtualization * Third-party scripts loaded asynchronously * Resource hints configured * Service worker implemented for caching * Font loading optimized * No long-running JavaScript on main thread * Optimize for 60fps (degrades gracefully under system constraints) ### Performance Metrics **Load Performance:** * First Contentful Paint (FCP): \< 1.5s * Largest Contentful Paint (LCP): \< 2.5s * Time to Interactive (TTI): \< 3.0s **Runtime Performance:** * Frame rate (target): 60fps * Frame rate (Low Power Mode): \~30fps typical * Input latency: \< 100ms * Cumulative Layout Shift (CLS): \< 0.1 ### Optimization Priorities 1. **Critical:** Reduce bundle size, optimize images, eliminate render-blocking resources 2. **Important:** Implement caching, lazy loading, code splitting 3. **Nice to have:** Advanced optimizations like resource hints, service workers, prefetching # Structure Source: https://setup.despia.com/best-practices/frontend/structure Essential patterns for building production-ready mobile applications with Despia. Learn how to structure root frames, handle device boundaries, implement scroll containers, and optimize touch interactions for native performance. ## Overview Despia provides an optimized native runtime environment using WKWebView (iOS) and Android WebView (Android), with integrated GPU acceleration, caching mechanisms, and native SDK interfaces. Your application's structural architecture (frame composition, scroll behavior, and element interactions) is determined by your codebase implementation. This guide documents structural patterns and best practices for building production-ready applications on the Despia platform. *** ## Root Frame Structure ### Foundation Architecture Applications require a root frame element that establishes the viewport boundaries and contains all structural components. ```html theme={null}
``` ### Implementation ```css theme={null} /* Root frame - establishes viewport boundaries */ .app-root { position: fixed; top: 0; left: 0; right: 0; bottom: 0; display: flex; flex-direction: column; overflow: hidden; } /* Safe area frames - handle device boundaries */ .safe-area-top { flex-shrink: 0; height: var(--safe-area-top, env(safe-area-inset-top, 0)); } .safe-area-bottom { flex-shrink: 0; height: var(--safe-area-bottom, env(safe-area-inset-bottom, 0)); } /* Header frame - fixed positioning */ .app-header { flex-shrink: 0; padding: 1rem; } /* Content frame - scrollable container */ .app-content { flex: 1; overflow-y: auto; overflow-x: hidden; -webkit-overflow-scrolling: touch; overscroll-behavior: contain; padding: 1rem; /* Hide scrollbar */ scrollbar-width: none; -ms-overflow-style: none; } .app-content::-webkit-scrollbar { display: none; } /* Footer frame - fixed positioning */ .app-footer { flex-shrink: 0; padding: 1rem; } ``` **Key Requirements:** * Use `position: fixed` for root frame (do not use `height: 100vh`) * Include dedicated safe area spacer elements * Header and footer use `flex-shrink: 0` (non-scrolling) * Content area uses `flex: 1` and `overflow-y: auto` (scrollable) * Apply `scrollbar-width: none` for native appearance *** ## Device Boundaries ### Safe Area Variables The Despia runtime automatically injects CSS variables for device-specific boundary insets (notches, status bars, home indicators). ```css theme={null} .element { padding-top: var(--safe-area-top); padding-bottom: var(--safe-area-bottom); } ``` ### PWA Fallback Implementation When deployed as a PWA, Despia-specific variables are unavailable. Implement fallback values using standard environment variables. ```css theme={null} .element { /* Despia variable → standard env() → default value */ padding-top: var(--safe-area-top, env(safe-area-inset-top, 0)); padding-bottom: var(--safe-area-bottom, env(safe-area-inset-bottom, 0)); } ``` ### Viewport Configuration Required meta tag configuration: ```html theme={null} ``` The `viewport-fit=cover` attribute enables safe area inset functionality. Omitting this attribute will prevent proper device boundary handling. *** ## Spacing System ### Unit Standards All spacing values (padding, margin, gaps) must use `rem` units to ensure consistent scaling and accessibility compliance. ```css theme={null} /* Correct implementation */ .card { padding: 1rem; margin-bottom: 1.5rem; gap: 0.75rem; border-radius: 0.5rem; } /* Incorrect - pixel values */ .card { padding: 16px; margin-bottom: 24px; } ``` **Standard Spacing Scale:** * `0.5rem` = 8px * `1rem` = 16px * `1.5rem` = 24px * `2rem` = 32px * `2.75rem` = 44px *** ## Interactive Elements ### Touch Target Requirements Interactive elements must meet minimum size requirements for reliable touch interaction: 2.75rem × 2.75rem (44px × 44px). ```css theme={null} .button, .tap-target, .icon-button { min-width: 2.75rem; min-height: 2.75rem; display: inline-flex; align-items: center; justify-content: center; } ``` **Spacing Requirements:** Maintain minimum spacing between interactive elements to prevent accidental activation. ```css theme={null} .button-group { display: flex; gap: 0.5rem; } ``` *** ## Scroll Containers ### Container-Based Scrolling Implement scrolling through dedicated container elements. Scrolling the body element interferes with fixed positioning, pull-to-refresh functionality, and safe area handling. ```css theme={null} /* Disable body scrolling */ html, body { margin: 0; padding: 0; height: 100%; overflow: hidden; } /* Dedicated scroll containers */ .scroll-container { overflow-y: auto; overflow-x: hidden; -webkit-overflow-scrolling: touch; overscroll-behavior: contain; /* Hide scrollbar */ scrollbar-width: none; -ms-overflow-style: none; } .scroll-container::-webkit-scrollbar { display: none; } ``` ### Pull-to-Refresh Prevention ```html theme={null} body { overscroll-behavior-y: none; } ``` **Note:** For accessibility requirements, use `scrollbar-width: thin` instead of `none`. *** ## Form Elements ### Input Sizing Standards Apply minimum font size of 1rem to all form inputs for readability and consistency. ```css theme={null} input, textarea, select { font-size: 1rem; padding: 0.75rem; border: 1px solid #ddd; border-radius: 0.5rem; } ``` **Note:** The Despia runtime automatically prevents zoom-on-focus behavior. ### Virtual Keyboard Adaptation The virtual keyboard reduces the available viewport height. Applications using the fixed frame structure automatically adapt to keyboard appearance without additional configuration. **Optional Keyboard Detection:** For custom keyboard-aware behavior: ```javascript theme={null} if (window.visualViewport) { window.visualViewport.addEventListener('resize', () => { const isKeyboardVisible = window.visualViewport.height < window.innerHeight * 0.75; document.body.classList.toggle('keyboard-visible', isKeyboardVisible); }); } ``` *** ## Element Behavior ### Text Selection The Despia runtime automatically prevents text selection across your application to provide a native app experience. This prevents users from accidentally selecting UI elements during interactions. **Enabling Text Selection:** For specific elements that should allow text selection (paragraphs, articles, code blocks), add the `selectable="true"` attribute: ```html theme={null}

This text can be selected by users.

Article Title

Users can select and copy this content.

const example = "selectable code"; ``` ### Touch Feedback Implementation Provide visual feedback for touch interactions: ```css theme={null} .button { transition: opacity 0.1s ease; } .button:active { opacity: 0.7; } /* Alternative: transform-based feedback (improved performance) */ .button:active { transform: scale(0.95); } ``` *** ## Complete Implementation Example ```html theme={null} Despia App

My App

This content can be selected and copied by users.

``` *** ## Common Implementation Errors ### Using height: 100vh for Root Frame ```css theme={null} /* Incorrect implementation */ .container { height: 100vh; } ``` **Issue:** Mobile browser UI elements (address bars, toolbars) dynamically alter viewport dimensions, causing layout instability and content overflow. **Resolution:** Use `position: fixed` for root frame elements. *** ### Enabling Body Element Scrolling ```css theme={null} /* Incorrect implementation */ body { overflow-y: auto; } ``` **Issue:** Body scrolling conflicts with fixed positioning, pull-to-refresh behavior, and safe area handling. **Resolution:** Implement dedicated scroll containers only. *** ### Omitting Safe Area Spacer Elements ```html theme={null}
...
``` **Issue:** Content is obscured by device notches, status bars, and home indicators. **Resolution:** Include safe area spacer divs in frame structure. *** ### Using Pixel Units for Spacing ```css theme={null} /* Incorrect implementation */ .card { padding: 16px; margin: 24px; } ``` **Issue:** Fixed pixel values do not scale proportionally and reduce accessibility. **Resolution:** Use rem units for all spacing values. *** ### Undersized Interactive Elements ```css theme={null} /* Incorrect implementation */ .icon-button { width: 1.5rem; height: 1.5rem; } ``` **Issue:** Elements below minimum touch target size result in poor user experience. **Resolution:** Maintain minimum 2.75rem (44px) dimensions for interactive elements. *** ### Incorrect Viewport Configuration ```html theme={null} ``` **Issue:** Safe area insets are not applied correctly. **Resolution:** Use `viewport-fit=cover` attribute. *** ### Missing PWA Fallback Values ```css theme={null} /* Incorrect implementation */ .header { padding-top: var(--safe-area-top); } ``` **Issue:** Despia-specific CSS variables are unavailable in PWA deployment mode. **Resolution:** Include standard environment variable fallbacks: ```css theme={null} .header { padding-top: var(--safe-area-top, env(safe-area-inset-top, 0)); } ``` *** ## Validation Checklist Before deployment: * Root frame uses fixed positioning (not `100vh`) * Safe area spacer divs included * Body element has overflow hidden (no scrolling) * Content containers handle scrolling * All spacing uses rem units * Interactive elements meet 2.75rem minimum size * Scrollbars hidden on scroll containers * Despia variables include PWA fallbacks * Tested in Despia iOS runtime * Tested in Despia Android runtime * Tested in PWA mode (fallbacks functional) * Verified on devices with notches * Keyboard behavior validated * Orientation changes handled (portrait and landscape) *** ## API Reference ### Despia Runtime Features The following features are automatically provided by the Despia runtime: * Input zoom prevention * Text selection prevention (native app behavior) * Native webview optimization (WKWebView on iOS, Android WebView on Android) * Performance optimization and caching * Native SDK integrations ### Selectable Attribute Enable text selection on specific elements: ```html theme={null}

Users can select and copy this text.

``` ### Root Frame Pattern ```css theme={null} .app-root { position: fixed; top: 0; left: 0; right: 0; bottom: 0; display: flex; flex-direction: column; overflow: hidden; } ``` ### Safe Area Pattern ```css theme={null} .safe-area-top { flex-shrink: 0; height: var(--safe-area-top, env(safe-area-inset-top, 0)); } .safe-area-bottom { flex-shrink: 0; height: var(--safe-area-bottom, env(safe-area-inset-bottom, 0)); } ``` ### Scroll Container Pattern ```css theme={null} .scroll-container { flex: 1; overflow-y: auto; overflow-x: hidden; -webkit-overflow-scrolling: touch; overscroll-behavior: contain; scrollbar-width: none; -ms-overflow-style: none; } .scroll-container::-webkit-scrollbar { display: none; } ``` ### Spacing Values | rem Value | Pixel Equivalent | Use Case | | :-------- | :--------------- | :-------------- | | 0.5rem | 8px | Tight spacing | | 1rem | 16px | Default spacing | | 1.5rem | 24px | Medium spacing | | 2rem | 32px | Large spacing | ### Element Sizing Standards * Interactive elements: `min-width: 2.75rem; min-height: 2.75rem` (44px) * Form inputs: `font-size: 1rem; padding: 0.75rem` * Interactive element spacing: `gap: 0.5rem` minimum ### Required Meta Configuration ```html theme={null} ``` # Editor Source: https://setup.despia.com/best-practices/updates/editor Changes made in the Despia editor affect the native binary. They require a rebuild and a new submission to the App Store and Google Play. ## Understand what a Despia editor change means Every change you make in Despia that affects the native binary, including adding an SDK, updating permissions, changing entitlements, and modifying the bundle version, produces a new binary that must go through store review. There is no way to ship a native binary change via OTA. Plan these changes carefully. Once submitted, you are waiting on Apple or Google. ## Always increment the bundle version Every time you submit a new binary, increment the bundle version in **Despia > Settings > Versioning**. This is how your web layer knows which native capabilities are available on a given device and how `despia.bundleNumber` gates features correctly. Use `MAJOR.MINOR.PATCH` format. Increment `PATCH` for small native fixes, `MINOR` for new native integrations, `MAJOR` for significant platform changes. Do not reuse a version number. Do not submit a new binary without incrementing first. ## Batch native changes where practical Every binary submission costs review time, typically 24-72 hours on iOS and faster on Android. If you know two native integrations are coming in the same release window, combine them into a single binary submission rather than two sequential ones. This is especially worth doing early in development when you are still adding foundational SDKs. ## Submit the binary before deploying the web layer that depends on it This is the most common mistake. The correct deployment order is: Deploying step 5 before step 4 completes means users on the old binary encounter web code that calls a native API that does not exist on their device. ## Use VersionGuard to manage the transition window After a binary update ships, there is always a period where some users are on the new binary and some are still on the old one. Gate any web UI or logic that depends on the new binary behind a version check so both groups get a coherent experience. ```jsx theme={null} import { VersionGuard } from 'despia-version-guard'; }> ``` Once the old binary version is sufficiently deprecated, you can remove the guard. ## Write clear review notes for non-obvious capabilities Both stores provide a reviewer notes field. Use it whenever your binary includes something that requires context: a permission that is not obviously connected to app functionality, a native SDK that might raise questions, or a capability that requires a test account to exercise. Reviewers have limited time. A brief, clear explanation of why a capability is present is almost always better than leaving it unexplained. ## Test on a real device before submitting Simulators and emulators miss issues that real devices catch. Test the new native capability on at least one physical iOS device and one physical Android device before submitting. Test on an older device if your users are likely to run older OS versions. ## Keep a version history Track what native capabilities were added in each binary version. This makes it straightforward to know which `min_version` to use in VersionGuard, debug issues reported by users on specific versions, and decide when a legacy code path can safely be removed. A simple comment in your codebase is enough: ```js theme={null} // v2.0.0 - added Stripe payments SDK // v2.1.0 - added biometric vault // v3.0.0 - added RevenueCat const PAYMENTS_VERSION = '2.0.0'; const BIOMETRICS_VERSION = '2.1.0'; const SUBSCRIPTIONS_VERSION = '3.0.0'; ``` # Interface Source: https://setup.despia.com/best-practices/updates/interface UI changes are the safest and most common use of OTA. They ship instantly, require no binary, and reach every user on the next app open. ## Ship UI changes via OTA without hesitation Any change to how your app looks, including layout, typography, colour, spacing, copy, and component structure, is a web layer change. Deploy it. Users get it silently on next launch. No review, no rebuild. This is the primary update path for the vast majority of work you will do on your app. ## Despia's defaults give you room to move Despia declares a broad set of permissions in every binary by default, including camera, microphone, contacts, and location. If your new UI uses any of those capabilities, the permission is already declared. You do not need a new binary just because you are adding a screen that accesses the camera or shows a contact picker. Ship it via OTA. The cases that require a new binary are new native SDK integrations, not new screens that use already-declared capabilities. ## Never include "coming soon" in a submission Do not ship placeholder sections, locked features with an unlock date, or any UI that implies something will appear later. Reviewers are looking for exactly this. A "coming soon" block is direct evidence that a feature was withheld from the submission and planned for OTA delivery, the precise behaviour Guideline 3.3.2 targets. If it is not ready, do not show it. Ship it fully when it is. ## Use VersionGuard to show coming soon safely If you need a teaser state, gate both the placeholder and the real feature behind a version check. The placeholder shows to users below the required binary version. The real feature shows to everyone who meets it. ```jsx theme={null} import { VersionGuard } from 'despia-version-guard'; }> ``` Reviewers always run the latest binary. They see the live feature. The placeholder is invisible to them. Nothing in the submission signals a workaround. ## Gate new UI on the binary version when it uses a new native API If you are shipping UI that calls a native capability added in a recent binary, wrap it in ``. Users on older binaries see nothing or a fallback instead of a broken experience. ```jsx theme={null} ``` Always ship and approve the new binary before deploying the web layer that depends on it. ## Be conservative with major UI overhauls Incremental updates, like new screens, adjusted layouts, and refreshed components, are fine and expected. A complete redesign that makes the app look and feel like a different product can attract review questions even when it is technically within the rules. If a change substantially alters what a user experiences, consider shipping it as a binary update. It gives users a clear update moment and removes any ambiguity about intent. ## Test on device before deploying Deploy a visible change, force close the app, wait a few seconds, reopen. Confirm the change appears. This takes two minutes and catches caching issues before they reach users. Display a build timestamp or version number somewhere accessible during development. It removes all guesswork about which version is running in the native container. # Logic Source: https://setup.despia.com/best-practices/updates/logic Business rules, data handling, API integrations, state management, routing - all web layer. They ship via OTA, silently, with no review and no rebuild. ## Ship logic changes via OTA Any change to your app's JavaScript, including API calls, data transformations, authentication flows, routing, state management, and third-party SDK integrations, is a web layer change. Deploy it via OTA. Users receive it on next launch without any action on their part. This includes native features exposed through the Despia JS SDK. The native capability is compiled into the binary. The code that calls it lives in your web layer, so it updates over the air like everything else. ## Despia's defaults cover more than you think Despia declares a broad set of permissions and capabilities in every binary by default: camera, microphone, contacts, location, and more. For the vast majority of logic changes that interact with those capabilities, the native side is already declared. You do not need a new binary to add a feature that uses the camera, reads contacts, or accesses location if Despia has already declared those permissions. This makes casual OTA deployments significantly more flexible. The cases that genuinely require a new binary are new native SDK integrations, not new uses of capabilities that are already declared. ## Adding web analytics or tracking tools You can add tools like PostHog, Segment, or Mixpanel via OTA at any time. No binary change needed. However, if any of these tools share data with advertisers, Apple's App Tracking Transparency (ATT) framework requires prior user consent, and that consent must be declared in the binary. If there is any chance you will add tracking tools after submission, declare ATT upfront in the binary. Store consent server-side so future OTA deployments can check it before initialising any analytics. ## Use OTA for critical fixes without hesitation If something is genuinely broken and the existing experience is unacceptable without a fix, ship it via OTA. Stores are not punitive when the reasoning is sound. If you can explain it clearly and it serves the user, it is defensible. Both Apple and Google explicitly permit OTA updates to the web layer of hybrid apps. A critical bug fix is the clearest possible use case. ## Know when logic changes need a binary Most logic lives in the web layer and ships via OTA. A small set of changes require a new binary because they touch the native side. | Change | Where it lives | How it ships | | ------------------------------------------------- | -------------- | -------------------- | | New API call or data transformation | Web layer | OTA | | New authentication flow (web-based) | Web layer | OTA | | Adding PostHog or Segment | Web layer | OTA | | New permissions (location, camera, notifications) | Native binary | App store submission | | New native SDK (payments, biometrics) | Native binary | App store submission | | Changing entitlements | Native binary | App store submission | If a logic change requires a new permission or a new native SDK, it is a binary change. See [Despia Editor Changes](/best-practices/despia-editor-changes). ## Gate logic that depends on a new binary version If you are deploying web logic that calls a native API added in a recent binary, check the version before executing it. ```js theme={null} import despia from 'despia-native'; function compareVersions(v1, v2) { const a = v1.split('.').map(Number); const b = v2.split('.').map(Number); for (let i = 0; i < Math.max(a.length, b.length); i++) { const diff = (a[i] || 0) - (b[i] || 0); if (diff !== 0) return diff; } return 0; } const MINIMUM_VERSION = '2.0.0'; if (compareVersions(despia.bundleNumber, MINIMUM_VERSION) >= 0) { initiateNewPaymentFlow(); } else { fallbackToLegacyFlow(); } ``` Always ship and approve the new binary before deploying the web layer that depends on it. ## Test logic changes the same way you test UI changes Deploy, force close the app, reopen, confirm. Display a build timestamp during development. If changes are not appearing, check CDN cache and confirm the app was fully closed rather than backgrounded. # Overview Source: https://setup.despia.com/best-practices/updates/overview Getting OTA updates right means your app stays fast, your users stay unblocked, and you never give a reviewer a reason to push back. ## Ship web changes via OTA. Ship native changes as a binary. The distinction is simple: changes made in Despia affect the native binary and require a new submission. Changes made to your web app are delivered over the air automatically. When in doubt, ask: does this change require a new SDK, a new permission, or a new entitlement? If yes, it is a binary change. Everything else is OTA. ## Despia's defaults give you more flexibility than you might expect Despia declares a broad set of permissions and capabilities in every binary by default: camera, microphone, contacts, location, and more. This means most casual OTA deployments that touch those capabilities are already covered. You are not starting from a minimal binary and adding permissions one by one. For the majority of feature additions, the native side is already declared and you can ship the web layer freely via OTA. The cases that still require a new binary are genuinely new native integrations: adding a payments SDK, biometrics, a third-party analytics library at the native level, or anything that was not included in Despia's defaults. ## Use the local server for offline apps If your app needs to work without connectivity, use `@despia/local`. It downloads your web build to the device and serves it from `http://localhost`. After the initial hydration, the app launches instantly from cache with no network request, and OTA updates still apply automatically in the background. Service workers technically work with Despia, but they are a pain to manage correctly. Misconfigured caching is one of the most common reasons OTA updates stop reaching users. It is exactly why we built the local server. We do not suggest service workers. ```bash theme={null} npm install --save-dev @despia/local ``` Enable it in **Despia > Settings > Local Server** after your first deployment with the plugin. ## Always ship the binary before the web layer that depends on it When a new native capability needs a new binary, the deployment order matters. Skipping steps 3 and 4 means users on the old binary encounter a web layer that calls a native API that does not exist on their device yet. ## Gate new features with VersionGuard When you ship web UI that depends on a specific binary version, wrap it with `despia-version-guard`. Users who have not yet updated to the required binary see nothing, or a fallback, instead of a broken experience. ```bash theme={null} npm install despia-version-guard ``` ```jsx theme={null} import { VersionGuard } from 'despia-version-guard'; ``` Because `despia.bundleNumber` is injected synchronously before your app starts, there is no loading state and no flash of incorrect content. ## Never use "coming soon" in a submission Do not include "coming soon" labels, placeholder sections, or anything that implies a feature will be added after submission. This is direct evidence to a reviewer that the feature was deliberately withheld and planned for OTA delivery. It hands them the justification they need to reject your app. The same applies to: "available in the next update," greyed-out locked features with an unlock date, and beta badges on unreleased sections. If a feature is not ready, do not show it. Ship it fully via OTA when it is, with no prior indication it was coming. ## Use VersionGuard to show coming soon safely If you want to tease upcoming functionality without the compliance risk, gate both states behind a version check. Show the placeholder only to users below the required version, and show the real feature to everyone who meets it. ```jsx theme={null} import { VersionGuard } from 'despia-version-guard'; }> ``` Reviewers always run the latest binary. They land on the live feature, never the placeholder. The coming soon state is only visible to users whose device has not yet received the new binary. Nothing in the submission signals a planned workaround to review. ## Be conservative with major UI changes Both stores permit OTA updates to the web layer, but Apple's interpretation of Guideline 3.3.2 is subjective. Updates that dramatically change what a user experiences can attract review questions even when they are technically compliant. If a change substantially alters the nature of your app, consider shipping it as a binary update instead. This keeps your versioning meaningful and removes any ambiguity. Reserve OTA for what it is best at: bug fixes, performance improvements, copy and UI iteration, and gradual feature rollouts. ## Test every OTA update before it reaches users Display a build timestamp or version hash somewhere in your UI during development. It removes all guesswork about which version is running. If updates are not appearing: check for service worker caching, purge your CDN cache, and confirm the app was fully force-closed. ## Use OTA for critical fixes without hesitation Stores are not punitive when the reasoning is sound. If something is genuinely broken and the existing experience is unacceptable without a fix, shipping it as an OTA update is reasonable and generally accepted by both Apple and Google. If you can explain the fix clearly and it serves the user, it is defensible. # Changelog Source: https://setup.despia.com/changelog New features, improvements, and fixes across the Despia platform. ## V3 to V4 migration - if a Despia page says "This site can't be reached" All Despia addresses are resolving normally again. On September 13, while internal infrastructure moved as part of the V3 to V4 migration, a few despia.com addresses such as v3.despia.com stopped resolving for a short time. This was a hosting-level address lookup issue. Your projects, builds and account data were never touched and remain fully secured. We are migrating Despia V3 to Despia V4, moving onto self hosted infrastructure and rebuilding Despia in Despia itself, so that we run on exactly what you run on. **If your browser still shows the error:** If you still see "This site can't be reached" or the code `DNS_PROBE_FINISHED_NXDOMAIN`, your device or network cached the failed lookup. It clears on its own within about 30 minutes. Any one of these clears it immediately: * **Restart your Wi-Fi router.** This fixes every device on the network at once. * **Switch networks briefly.** Your phone's hotspot works. * **Set your device DNS to 1.1.1.1.** On Mac: System Settings, Network, your connection, Details, DNS, add 1.1.1.1. On Windows: Settings, Network and internet, your connection, DNS server assignment, Edit, Manual, IPv4 on, Preferred DNS 1.1.1.1. On iPhone: Settings, Wi-Fi, the i next to your network, Configure DNS, Manual, add 1.1.1.1. On Android: Settings, Network and internet, Private DNS, enter one.one.one.one. Then reload the page. In Chrome, if it still fails, open `chrome://net-internals/#dns` and click Clear host cache. No dashboard configuration changes, rebuild steps, or integration code changes are required. The only action needed is clearing a stale DNS cache on your own device, and only if you are still seeing the error. ## Age Assurance Despia apps can now read the age range the operating system already holds for a user, on iOS and Android, from a single call that returns the same shape on both platforms. Apple Declared Age Range and Google Play Age Signals sit behind one bridge, so a build can gate mature content, unlock adult-only features, or soften an experience for a minor without ever asking for a birthdate. **What ships today:** * **`ageassurance://check`.** Fires the platform consent sheet and delivers a verdict to `window.onAgeAssuranceResult`. Pass `gates` as up to three comma-separated ages you actually gate on, and `id` to correlate the response. Nothing throws and nothing rejects: every outcome, including every failure, arrives as a verdict with a `status` of `shared`, `declined`, `verification_required`, `not_applicable`, `unavailable`, or `error`. * **Three-state gates.** Each key in the `gates` map answers `true`, `false`, or `null`, where `null` means the requested age falls inside the returned band and the operating system genuinely did not answer. Test for `=== true` before unlocking anything. Reading `null` as `true` is what hands adult content to a minor and costs an app its listing. * **Assurance tiers.** Every shared verdict carries how the age was established: `self_declared`, `guardian_declared`, `verified_estimated`, `verified_strong`, or `unknown`. Require a stronger tier for the flows that need one. * **`ageassurance://resolve`.** Android only. Sends a user into Play's own verification flow when Google answers `verification_required`. On iOS the command is absent from `ageAssuranceFeatures` and answers `error` with `unsupported_action`. * **`ageassurance://last` and `ageassurance://forget`.** `last` replays the retained verdict without prompting. `forget` drops it on the native side and resets `ageAssuranceVerdict` to `null`, which is what you call on logout or an account switch so the next person to sign in cannot read the previous user's age range. * **Capability globals.** `ageAssuranceAvailable`, `ageAssuranceProvider`, `ageAssuranceReason`, `ageAssuranceFeatures`, and `ageAssuranceVerdict` are plain globals that never prompt. Branch on these rather than on the operating system version or the user agent. They exist only in the top-level page: an `