# 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}
```
***
## 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 `
## Easy OAuth
Social sign-in can now finish on your own domain instead of on a custom scheme, with the provider's redirect landing directly in your app and no intermediate callback page to build.
Pass `type=https` on the `oauth://` scheme and the runtime watches your entire host for the provider's redirect. When it lands, the secure browser session closes and your app loads that exact callback URL, query string and fragment intact.
**What ships today:**
* **The `type=https` parameter.** One flag on the `oauth://` scheme selects the flow. There is no callback path to configure, because the session watches the whole host and any redirect home finishes the login.
* **Verbatim delivery.** The callback URL loads exactly as the provider sent it, so implicit flow providers that return tokens in the fragment now work. The legacy flow rebuilds from path and query only, which drops the fragment.
* **Cookies land in your app.** The redirect is recognised at navigation time and never fetched inside the browser session, so your app makes the only real request and any `Set-Cookie` header on it is written to your app's own store.
* **Capability detection.** `despia.httpsAuthCallback` reports whether the current device can finish on your domain, so your page can pick the right `redirect_uri` before it builds the authorize URL. That choice cannot be changed once the flow starts.
* **Association failure reporting.** `window.onAuthCallbackError(host, code)` fires when the domain association is broken, a case iOS otherwise reports with the same code it uses for an ordinary user cancellation.
* **iOS and Android parity.** iOS runs the session on `ASWebAuthenticationSession` (17.4 and later). Android runs an Auth Tab where the browser supports it, falling back to a verified App Link with a Custom Tab.
Setup is required before the flow can work. Your domain needs an `apple-app-site-association` file carrying a `webcredentials` section and an `assetlinks.json` file carrying your Play app signing key fingerprint, Associated Domains has to be enabled on your main Bundle ID, and Web App Synchronization has to be turned on under Addons. The capability is written into the binary at build time, so it reaches users only through a store deployment on a raised version. The [Easy OAuth documentation](/native-features/oauth/easy-login-flow) covers each step, including where to find the SHA-256 fingerprint in Play Console.
This is opt-in and additive. Existing integrations are unaffected: a `despia()` call that does not pass `type=https` runs the legacy custom scheme flow unchanged, needs no code edits, and keeps working on devices that cannot support the new flow.
## Android 16 KB page size compatibility
Android builds now ship native libraries that meet Google Play's 16 KB page size requirement, and every build is checked against that requirement before the bundle is uploaded.
Previously the Android runtime included a build of `libsqliteJni.so` produced before NDK r28. Its segments were aligned correctly, so the usual alignment check passed, but its RELRO region stopped partway through the writable segment. The loader cannot apply protection to a partial page, so it dropped that protection entirely, and Google Play flagged the bundle for arm64-v8a and x86\_64.
**What changes:**
* **Updated SQLite native library.** The runtime now resolves androidx.sqlite to a version built with NDK r28, which pads the RELRO region out to a 16 KB boundary. This is the library named in the Play Console warning.
* **Release gate on every build.** Each Android build is inspected after compilation and before upload. Any 64-bit native library that is not 16 KB compatible fails the build with the offending file named, so a future dependency update cannot reintroduce the problem silently.
* **Native libraries stay uncompressed.** Libraries are kept uncompressed and page aligned inside the package, so the system maps them directly instead of extracting copies at install time and discarding their alignment.
No dashboard configuration or integration code changes are required. Rebuild your Android app to pick up the updated runtime.
## Native in-app rating prompt
The `rateapp://` scheme now presents the operating system's native star rating prompt as a sheet inside your app, so users can leave a rating in a single tap without the session being interrupted.
Previously the scheme could route users out to the store listing depending on the `showcustomrateappdialog` configuration flag. A rating request cost the user their place in the app and required a manual trip back, which is the main reason in-app rating prompts convert poorly.
**What changes:**
* **Native prompt on the bare scheme.** Both `rateapp://` and `rateapp://rating` present the native rating sheet in place and never navigate away from your app.
* **Explicit opt-in for the review composer.** `rateapp://review` is now the only variant that leaves the app. It opens the store's write-review composer for your listing, which makes it suited to an explicit "Write a review" button rather than an automatic prompt.
* **`showcustomrateappdialog` is deprecated.** The flag is no longer read. The bare scheme always shows the native prompt regardless of what it is set to.
The operating system rate limits the native prompt to a small number of displays per user per year and suppresses it silently once that budget is spent. Treat the command as a request rather than a guarantee, and do not block a flow or show a confirmation on the assumption that the user saw it.
Existing `rateapp://` calls need no changes and pick up the native prompt automatically. If your app relied on `showcustomrateappdialog` to send users to the store listing, switch those calls to `rateapp://review` to keep the previous behaviour.
## Multipage RevenueCat paywalls
Paywalls built as multi-screen flows in RevenueCat now render every screen in Despia builds, so a paywall authored as Screen 1, Screen 2, then the purchase screen arrives on device in that order.
Previously these paywalls opened directly on the final purchase screen. RevenueCat gates multipage rendering behind a minimum native SDK version and falls back to the last screen on anything older, so the earlier screens were dropped before the app ever drew them. Nothing in the paywall design or the offering configuration caused this, which is why affected paywalls still previewed correctly in the RevenueCat dashboard.
**What ships today:**
* **iOS runtime.** purchases-ios and RevenueCatUI move from 5.80.3 to 5.83.0, the first release that renders multipage paywalls.
* **Android runtime.** purchases and purchases-ui move from 10.13.0 to 10.16.0, the matching Android release.
* **Existing paywall calls.** The `revenuecat://launchPaywall` scheme is unchanged. Paywalls launched with a specific offering identifier, and paywalls launched with the default offering, both pick up multipage rendering with no change to how they are called.
A new build is required. The RevenueCat SDK is compiled into the native binary, so multipage paywalls appear only in builds created after this release. No dashboard configuration changes or integration code changes are needed.
## OneSignal with the advertising ID disabled
Android apps that disable the advertising ID can now use OneSignal. Previously, turning off tracking excluded the OneSignal SDK from the build entirely.
The OneSignal SDK declares `com.google.android.gms.permission.AD_ID` by default, which contradicts a Play Console data safety declaration stating the app does not collect an advertising identifier. Rather than ship a manifest that conflicts with that declaration, the SDK was dropped from tracking-disabled builds, which also removed push notifications along with it.
**What changes:**
* **OneSignal ships without AD\_ID.** The permission is stripped from the build instead of the SDK being excluded. Push notifications, in-app messages, tags, and segments all work as normal.
* **Only advertising-ID features are unavailable.** Anything that depends on the identifier itself - ad network attribution and GAID-based audience matching - stays off. Everything outside that scope is in.
* **The data safety declaration stays accurate.** The shipped AAB does not declare the advertising ID permission, so an app declaring no advertising identifier collection matches what is actually built.
Existing apps need a fresh native build to pick this up. No dashboard configuration or integration code changes are required beyond leaving the tracking setting off.
## File Picker
File inputs now open the native picker that matches what the upload actually accepts, driven entirely by the standard `accept` attribute. Existing upload forms get native pickers with no code change.
**What ships today:**
* **Accept-driven routing.** The `accept` value is parsed when the user taps the input, mapped to native file types, and the matching picker is presented scoped to those types. Extensions, MIME types, and wildcards are all understood, and the mapping is generic rather than a fixed list, so `.mp3` and `.dwg` resolve the same way.
* **Photo picker for media.** `image/*`, `video/*`, `image/*,video/*`, and concrete types such as `image/png` open the system photo picker filtered to that type, keeping users out of the file system entirely.
* **Documents picker for everything else.** Audio, PDFs, archives, and mixed media open the documents picker scoped to the accepted formats. An unrecognised extension is dropped from the scope rather than blocking the picker.
* **Direct camera capture.** Pairing `capture` with a media `accept` skips the picker and opens the camera. `environment` selects the rear lens, `user` the front. On non-media inputs `capture` is ignored and normal routing applies.
* **Multi-select.** The standard `multiple` attribute enables multi-select in whichever picker opens, including the documents picker. Cancelling leaves the `FileList` empty and raises no error, so a cancel stays distinguishable from a genuine failure.
* **Browser picker handoff.** The `filepicker://type?native_overwrite=true` command returns every file input on the page to the default web picker. The setting survives navigation and resets on relaunch, so pages that depend on it should set it on load.
* **Configurable upload sources.** The sources offered when an input has no `accept` value are set per app in the Despia Editor: camera and gallery, camera only, or gallery only.
Selected files arrive as ordinary `File` objects backed by a URL, so a long recording or a large video is streamed on upload rather than copied through memory first.
Full documentation is available on the [File Picker](/native-features/file-picker) page.
No integration code changes are required for accept-driven routing. Changing the upload source mode in the Despia Editor requires a fresh native build.
**Packages:**
* [despia-native](https://www.npmjs.com/package/despia-native)
## Custom URL scheme
The scheme your app registers with iOS and Android is now editable in **Despia > App > Settings > Dynamic App Source > URL Scheme**.
Until now the value was generated from your app name on the first build. App names containing spaces, punctuation, accented characters, or a leading digit produced a scheme the operating system will not register, and App Store Connect flags the malformed entry at submission rather than at build time.
**What ships today:**
* **Editable field.** Enter the value on its own, with no `://` suffix and no path. Entering `myapp` registers `myapp://`.
* **Independent of app name.** Renaming your app no longer changes the scheme, so a live login flow survives a rename.
* **Compliance control.** Apps whose generated scheme was rejected can now set a valid one without renaming the app itself.
The scheme is what closes a native authentication session and hands tokens back to your WebView, so changing it on a live app means registering the new value alongside the old one in every OAuth provider dashboard before shipping. Existing apps keep their current scheme until the field is edited. See [URL Scheme](/native-features/url-scheme) for the compliance rules and the migration checklist.
Editing the field is a native configuration change and requires a fresh build. Until that build is installed, the binary on the device answers to the old scheme only, and return links fire silently with no error.
## Cutting ties with Cactus, investing in our own local AI
Despia demo apps no longer ship with the Cactus inference backend, and Cactus is being removed as the engine behind the local AI feature entirely.
Cactus was open source when we adopted it. That changed. The current license is source-available, with commercial use gated behind funding and revenue thresholds. Despia does not ship one app, it ships an engine inside every customer's app, so a dependency like that becomes a liability for every project built on the platform.
We are not willing to build Despia apps on infrastructure that is not open source, or that we do not control enough to guarantee quality and fair pricing. So we are building the engine ourselves.
**What we are investing in:**
* **A fully open source local AI ecosystem.** Apache-2.0, no usage limits, no revenue thresholds, no license checks, no telemetry. Built on llama.cpp and whisper.cpp, both MIT.
* **Usable outside Despia.** The engine ships as standalone Swift, Kotlin, and TypeScript packages, so React Native and Capacitor apps can use it directly without touching Despia at all.
* **First-class support inside Despia.** In Despia it stays fully low-code - the `intelligence` capability keeps its existing action names, arguments, and return shapes, so integration code written today does not change when the new engine lands.
* **Streaming, tool calling, and MCP.** Token streaming, a native tool-calling loop wired to your app's own module actions, and an MCP client, across iOS, Android, macOS, and Windows.
No dashboard configuration changes, rebuild steps, or integration code changes are required today. Apps relying on on-device inference should treat the capability as unavailable until the new engine is released.
## PWA service workers
Despia apps can now run the service worker your web app already ships, giving you a working offline mode driven entirely by standard web APIs with no build tools and no native offline layer.
**What ships today:**
* **Service worker registration.** Registration, installation, activation, and fetch handling behave the same inside the app as they do in a browser PWA, on both iOS and Android.
* **Cache storage.** Precached documents, assets, and API responses are served when the device has no connection, using whatever caching strategy your worker defines.
* **Offline state through web APIs.** IndexedDB and localStorage are available for persisting sessions, drafts, and queued writes, so login screens and app state survive an offline launch.
Enable it under App > Settings > Offline Support > PWA, then increase the version code and rebuild. Offline support is a native runtime setting, so builds produced before the toggle was flipped ignore service worker registration entirely.
Your service worker is the single point of failure. A worker that caches once and never refreshes will pin users to an old release, block over the air updates, and can open the app to a white page when a cached document references assets that no longer exist. Serve from the network first while online, write successful responses back into the cache, and change the cache name on every release so stale entries are cleared on activate. See [PWA service workers](/native-features/service-workers) for the reference implementation and the offline verification test.
A dashboard change and a fresh native build are required. No integration code changes are needed beyond the service worker in your own web app.
## Usage Descriptions
Every permission your app declares is now an editable field under App > Settings > Usage Descriptions. You control the wording of each iOS purpose string, and clearing a field removes the permission from the build entirely.
Until today, every Despia binary shipped a broad default permission set so that any web API you reached for would work without a new native build. Apple now runs an automated analysis of purpose strings at submission and rejects binaries under Guideline 5.1.1 when a string is generic or when a permission is declared for a resource the app never uses.
**What ships today:**
* **Editable purpose strings.** Each of the seventeen supported permissions has its own field. Write your own copy describing what your app does with the data, or use Revert to Default to restore the Despia wording and edit from there.
* **Removal by clearing the field.** An empty field removes the key from `Info.plist`. It also strips the feature, its native code, and any dependencies or libraries it imported, so exported Xcode and Android Studio projects contain no dead capability.
* **Android manifest mapping.** The iOS keys are mapped to their Android equivalents. Filling in a usage description adds the matching feature and permission to the Android manifest, and clearing it removes them from both platforms. The Health keys and Tracking are iOS only.
* **Supported keys.** Camera, Microphone, Photo Library, Save to Photos, Location While Using, Background Location, Always-On Location, Bluetooth, Contacts, Calendar, Face ID, Health Read, Health Write, Speech Recognition, NFC Scanning, and Tracking. Push Notifications is also a field, holding the copy for your own priming screen, since iOS does not allow custom text in the system notification alert.
A field must be completely empty for the permission to be removed. A single space counts as a value and the key stays in the build.
Removing a permission removes it from the binary, and no over-the-air update can add it back. If web code later calls an API whose usage description was removed, iOS terminates the app. Gate native capabilities on the app version using the `getappversion://` scheme, ship the binary carrying the new usage description first, then push the web update that enables the feature.
Existing apps keep their current permission set until you edit it. Changes to usage descriptions apply on the next native build.
## Open sourcing the Despia V4 runtime
Ahead of the V4 release, the core runtime is being prepared for publication as open source under the MIT license. The editor follows later this year.
The V4 runtime is a dynamic kernel. Every capability above it - WebView, native UI, web UI, backend - is a package that the kernel loads at runtime. What gets open sourced is the kernel, the brain of the system. The packages stay in a closed source folder and are not part of the release.
**What is included:**
* **The kernel.** Package loading, command dispatch, and lifecycle management - the layer everything else runs on.
* **The component contract.** The components folder and the attribute model that text, image, button, and every other primitive are declared against. Anyone can implement against it.
* **Nothing above the kernel.** The Despia WebView package, native UI package, web UI package, and backend package are excluded and remain closed source.
**Why:**
* **Auditability.** Enterprises evaluating Despia can read the runtime that executes their apps rather than take its behaviour on trust. This is the main blocker we hear in enterprise reviews.
* **Compliant deployments.** Under MIT, an organisation can vendor the kernel into its own internal build where compliance requires it, without a dependency on Despia infrastructure.
* **A usable runtime on its own.** The kernel is functional without our packages. Writing your own WebView package, native UI package, and component implementations against the contract gives you a working runtime at no cost.
This is not a wind-down of the commercial product. Despia's hosted platform, build pipeline, OTA updates, native packages, and support continue as they are, and open sourcing the kernel is intended to expand where Despia can be adopted, not reduce it. The goal is for Despia - hosted or self-hosted - to be the default runtime for AI-assisted development of production apps that stay maintainable and stay current through OTA updates.
Nothing changes for existing apps. No dashboard configuration changes, rebuild steps, or integration code changes are required. Repository and license details will be published with the V4 release.
## AppsFlyer on Android, deferred deep linking, and ad revenue tracking
The native AppsFlyer integration now runs on Android alongside iOS, delivers deep link metadata to your web layer through a callback, and adds impression-level ad revenue reporting. Attribution, events, identity, and consent behave the same across both platforms and are driven entirely from your web layer.
**What ships today:**
* **Android support.** AppsFlyer now works on Android as well as iOS. The SDK is embedded and managed natively on both platforms, so installs, attribution, events, and deep links flow the same way.
* **Deferred deep linking.** The context you attach to a OneLink, the product, creator, invite code, or offer, survives a store install and arrives on first launch. Despia resolves the link natively and delivers the payload to `window.onAppsFlyerDeepLink`, and your app reads it to open the right content. Put the IDs a fresh install must receive into `deep_link_sub1` through `deep_link_sub10`.
* **Ad revenue tracking.** The `log_ad_revenue` scheme reports impression-level ad revenue per network across Meta, TikTok, and AdMob, feeding LTV and ROAS reporting per acquisition channel.
* **Live session and attribution.** A new session starts every time the app returns to the foreground, and attribution is re-injected the moment it resolves or updates, so `despia.appsFlyerAttribution`, `despia.appsFlyerReferrer`, and `despia.appsFlyerUID` stay current without polling.
* **Expanded attribution object.** The object now exposes a `raw` field with every key AppsFlyer returned, `pid` and `c` aliases, and campaign cost fields.
To use deep linking, define `window.onAppsFlyerDeepLink` early in your page and read the payload to open the right content. Adding your AppsFlyer Dev Key, OneLink domains, or ad platform IDs in the Despia Editor requires a fresh native build to take effect. See [Deep Linking](/analytics/appsflyer/deeplinking), [Ad Revenue](/analytics/appsflyer/ad-revenue), and [Campaigns](/analytics/appsflyer/campaigns).
**Packages:**
* [despia-native](https://www.npmjs.com/package/despia-native)
## Sandbox Testing
Sandbox Testing lets a compiled app be repointed at a test environment at runtime, without rebuilding. Testers can redirect the running app to a staging host and start URL, run QA against the test backend, then restore the production app when they are done.
**What ships today:**
* **Point at a test environment.** The `sandbox://set` command overrides the runtime host and start URL, persists the override, and reloads the WebView into staging. Pass a `host`, a `start_url`, or both, and the missing value is derived from the other.
* **Return to production.** The `sandbox://clear` command restores the production host and start URL from the snapshot captured at launch and reloads into the production app.
* **Read the current environment.** `despia.isSandbox` is `true` whenever a test environment is active and `false` in production, so the page can branch on which backend it is talking to.
* **Persists across launches.** An active override survives a cold launch. Every launch into a test environment shows a reminder alert, and each switch shows a native alert, so it is always clear which environment is live.
* **Identical on both platforms.** The commands, the persisted behaviour, and `despia.isSandbox` work 1:1 on iOS and Android.
See the [Sandbox Testing](/native-features/sandbox) documentation for the full parameter reference and QA flow.
The feature is part of the native runtime, so it is available in builds created from today onward. No dashboard configuration or integration code is required to receive it, and the commands are callable directly from your loaded page.
**Packages:**
* [despia-native](https://www.npmjs.com/package/despia-native)
## Native audio player
The native audio player is now. Hand the native side a fixed queue or a paginated feed and it owns playback end to end: background audio, lock-screen and Control Center controls, interruption handling, and route changes, all without your page touching an audio element.
**What ships today:**
* **Queue and feed playback.** The `audio://setqueue` scheme takes a fixed track list, and `audio://setfeed` points the player at a paginated server endpoint that it fetches ahead of time so the queue never runs dry.
* **Full transport controls.** `audio://play`, `pause`, `next`, `prev`, `playat`, `seek`, `skipforward`, `skipback`, and `speed` drive playback from your own UI, and the lock-screen buttons fire the same commands.
* **Live config without a restart.** The `audio://config` command toggles loop, playback speed, skip interval, and which lock-screen controls show, all while playback continues.
* **Lock screen and background.** Every track renders a Now Playing card across the lock screen, Control Center, Dynamic Island, CarPlay, and watch. Playback keeps running when the app is backgrounded or the screen locks, pauses for calls and Siri, and resumes automatically.
* **State through one callback.** All state changes arrive through a single `window.onAudioEvent` handler, including a 1 Hz progress tick, so your UI stays correct no matter who pressed the button.
* **Server-side progress tracking.** The `audio://webhook` command POSTs playback milestones to your backend, including ones triggered from the lock screen while your web app is suspended.
* **Reload survival.** Native playback outlives web view reloads and in-app navigation. Call `audio://sync` on page load to re-hydrate your UI to what is already playing.
Full scheme, parameter, and event reference is in the [Audio documentation](/native-features/audio-player).
**Packages:**
* [despia-native](https://www.npmjs.com/package/despia-native)
Enable the feature in the Despia Editor and trigger a fresh native build to pick it up. No integration code changes are required beyond wiring the `audio://` commands and the `window.onAudioEvent` handler documented above.
## GPS single reads and long-range background tracking
The GPS Location feature now supports one-off location reads and a coarse long-range mode that keeps recording while the app is backgrounded and survives the app being force-killed.
**What ships today:**
* **Single location reads.** The `location://simple` scheme returns one GPS fix and stops, with no continuous session. It prompts only for While-Using permission and delivers the result as a one-element session array, which suits stamping a position onto a form submission, a check-in, or a photo.
* **Long-range background mode.** Setting `movement` to `50000` (500 metres) or higher on a `location://` call switches GPS into a low-power mode that fires roughly every 500 metres of travel, keeps running in the background, and survives termination. When iOS relaunches the app for the next point, Despia restores the session and resumes delivery automatically.
* **Automatic mode selection.** The tracking mode is chosen from the `movement` value with no separate flag. Values below `50000` keep continuous high-accuracy tracking, values of `50000` or higher select long-range mode.
Long-range mode is driven by distance rather than the `buffer` timer, requests Always location permission so the OS can wake a terminated app, and depends on the Background Location addon. Always call `stoplocation://` when a long-range session ends, since the persisted session is only cleared on stop and a later relaunch can otherwise resume tracking silently.
Full details are in the [GPS Location documentation](https://docs.despia.com/features/gps-location).
Single reads and continuous foreground tracking require no setup and work on existing builds. Long-range background tracking requires enabling the Background Location addon in the Despia Editor and triggering a fresh build, since the background capability is compiled into the app binary and cannot be applied over-the-air.
**Packages:**
* [despia-native](https://www.npmjs.com/package/despia-native)
## HealthKit raw samples
The `readhealthkit://` scheme now accepts an opt-in `raw=true` parameter that returns every individual quantity sample with its own timestamp, instead of the default one averaged value per day.
The daily average was computed on-device, so the underlying samples never reached the web app. With `raw=true` they do, which unlocks intra-day and overnight analysis such as tracking how HRV moves across a night of sleep.
**What ships today:**
* **Raw quantity samples.** Add `raw=true` to a `readhealthkit://` read to receive individual samples, each with `startDate`, `endDate`, `value`, and `unit`, sorted oldest to newest.
* **Works across the existing call shape.** The parameter combines with comma-separated identifiers and multi-day `days` windows, and applies to quantity types only. It has no effect on sleep or workout reads, which already return per-sample data.
Adding `raw=true` is non-breaking. Calls without it return exactly the same daily aggregate as before. See the [Apple Health docs](/health-data/apple-health) for the full sample shape and examples.
This feature requires an app build that includes the update. On older builds `raw=true` is ignored and the read returns the daily aggregate, so rebuild from the Despia Editor to enable it. No dashboard configuration changes are required.
**Packages:**
* [despia-native](https://www.npmjs.com/package/despia-native)
## Service-worker offline support (opt-in)
Apps that load from a remote `https` origin and ship a Progressive Web App service worker can now run that worker inside the native WebView on iOS and Android, serving offline from the worker's own cache and behaving like an installed PWA. This is opt-in per app and off by default.
The native layer gets out of the way once the feature is on, so the worker owns the offline experience instead of the app dropping to its local-HTML fallback. Enabling it is the same on both platforms: select PWA in the editor's Offline Support section.
**What ships today:**
* **Worker-served offline on both platforms.** With the feature on, navigations flow through the service worker whether online or offline, and the native offline fallback no longer hijacks the navigation into the bundled local HTML.
* **App-Bound Domains on iOS.** WKWebView only runs service workers when the app declares its domains as App-Bound Domains, so opted-in iOS builds register the app host plus its in-app navigation domains automatically. On Android the WebView runs service workers natively, with none of the App-Bound constraints below.
* **Cold-start offline bootstrap.** On a cold offline launch the app loads a tiny same-origin page, waits for the worker to become ready, then soft-navigates to the target so the worker serves the cached shell.
**How to enable it:**
* In the Despia editor, open the Offline Support section and select PWA (service workers). The build registers the required domains automatically.
* If the main view loads third-party domains not already in the app config, list them as extra service-worker domains.
**Requirements and constraints:**
* The web app must be a client-side-routed SPA. A fresh top-level navigation to an uncached route is not served offline; in-page routing is.
* The origin must be served over `https`, the first launch must be online so the worker can install, and the persistent data store must stay intact. Clear-data and reset flows wipe the worker and its caches.
* On iOS only, App-Bound Domains is capped at 10 entries, listed individually, and every domain the main view navigates to in-webview must be listed. Full-page OAuth redirects or popup logins to unlisted domains (for example `accounts.google.com`, `m.facebook.com`, or `appleid.apple.com`) fail under App-Bound mode and should be routed through native sign-in instead.
For new offline-first apps, the Despia Local Server (`@despia/local`) is the cleaner option: it serves the build from `http://localhost`, so service workers and other PWA features work natively with none of the App-Bound Domains constraints above. See [https://setup.despia.com/local-server/introduction](https://setup.despia.com/local-server/introduction).
This feature is off by default and must be enabled per app. To turn it on, open the Offline Support section in the Despia editor and select PWA, confirm the web app is a client-side-routed SPA, and on iOS list any third-party in-webview domains. No changes are needed for apps that do not opt in.
## Android runtime now supports Kotlin
Despia Android now runs on both Java and Kotlin. Previously, the Android layer was Java-only, which blocked integration with modern SDKs that require Kotlin.
Adding Kotlin support alongside the existing Java runtime removes that constraint and allows Despia Android builds to integrate with the growing set of SDKs that depend on Kotlin.
No dashboard configuration changes are required. Developers integrating Kotlin-based SDKs should rebuild their app to pick up the updated runtime.
## Vision OCR
Despia apps can now run optical character recognition on any image using the device's native text engine, returning the extracted text straight to your web app. Recognition happens entirely on-device, so it works offline and never sends image data anywhere, with no server round-trips and no third-party API keys.
**What ships today:**
* **Multiple image sources.** Recognize a hosted HTTPS image, a `data:` URI, a raw base64 string, a photo the user picks, or a multi-page document scan, all through one capability.
* **Native pickers.** The `@imagepicker` token opens the system photo library and the `@filepicker` token opens an image-filtered file browser, with the selection flowing straight into recognition.
* **Document scanning.** The `@documentscanner` token opens the native document camera with edge detection and perspective correction, recognizing every captured page and returning the combined text in one result.
* **Structured results.** Each result delivers the full text plus a per-line breakdown through the `window.onVisionEvent` callback. Lines carry a confidence score on iOS where the engine exposes one. Output is normalized so each line is trimmed and blank runs collapse before delivery.
* **Parallel recognition.** Jobs run independently and are correlated by a caller-supplied `id`, so several images can be processed at once.
* **Script hints.** An optional language hint constrains recognition to a known script for non-Latin text, though both platforms auto-detect by default.
See the [Vision OCR documentation](/machine-learning/vision/ocr) for the full event reference, error codes, and sample payloads.
**Privacy by design.** Because recognition runs fully on-device, no image or extracted text is ever transmitted to Despia or any third party. Sensitive content stays on the user's device, which removes the third-party processing and data-transfer exposure that frameworks like GDPR and HIPAA are concerned with. This makes the feature well-suited to handling personal and health-related data, though overall compliance still depends on how your app stores, transmits, and handles that data end to end.
**Packages:**
* [despia-native](https://www.npmjs.com/package/despia-native)
Vision OCR is available in the current `despia-native` package and requires a fresh native build to pick up the new capability. No dashboard configuration or integration credentials are required.
## OneSignal push routing and metadata
Push notifications can now deliver a client-side route and arbitrary metadata to the web layer, enabling deep-link navigation and state restoration on notification tap without a full WebView reload.
**What ships today:**
* **Path-based routing.** Send a `path` key in the OneSignal `data` object (e.g. `/account/orders/4567?tab=tracking`). Despia applies the route via `pushState` and fires `popstate` on notification tap. Most SPA routers react to the `popstate` event and navigate automatically with no additional code required on the web side.
* **Metadata delivery.** Send a `metadata` key in the `data` object with any JSON value. The payload is delivered to `window.onNotificationEvent` as an object when sent via the REST API, or as a string requiring `JSON.parse` when set via the OneSignal dashboard Additional Data fields. Use it to restore app state on open.
* **`window.onNotificationEvent` hook.** For routers that do not react to a synthetic `popstate`, or when using `metadata`, define `window.onNotificationEvent` at the top of your entry bundle. The host app calls it on every notification tap after applying the URL change. If the handler is not defined when the event fires, the event is lost and will not be replayed.
* **Legacy `data.url` behavior unchanged.** Pushes that send only `data.url` continue to trigger a full WebView navigation. They now also fire `window.onNotificationEvent` with `url` in the payload, which is a no-op if the handler is not implemented.
Refer to the [push reference documentation](/native-features/onesignal/reference#deep-linking-and-routing-from-a-notification) for the full payload shape, timing table, and backend examples.
No dashboard configuration changes but a rebuild is required if you want to use the new API version. Add `path` and `metadata` to your OneSignal `data` object to use the new behavior.
## PostHog analytics bridge
Despia now ships a native PostHog integration, exposing event capture, user identification, group analytics, screen tracking, feature flags, and GDPR consent controls through `despia()` schemes and four injected globals. Analytics run inside the native PostHog SDK, with batching, persistence, and delivery handled outside the WebView.
**What ships today:**
* **Event capture.** The `posthog://capture` scheme fires named events with optional URI-encoded JSON properties. Device context (OS, app version, screen size) is attached automatically by the native SDK, and your keys take precedence on any conflict.
* **Identity and aliasing.** The `posthog://identify` scheme links the device to your internal user ID and stores person properties on the PostHog person record. The `posthog://alias` scheme merges anonymous pre-signup history into the identified profile in a single call.
* **Group analytics.** The `posthog://group` scheme associates subsequent events with an organization, team, or workspace for B2B funnels. Group types are created automatically on first use, up to PostHog's hard limit of five per project.
* **Screen tracking.** The `posthog://screen` scheme sends a `$screen` event with the provided name, suitable for route-change tracking.
* **Super and person properties.** The `posthog://register` and `posthog://unregister` schemes manage session-level super properties appended to every captured event. The `posthog://set_person_properties` scheme updates the person record without capturing an event.
* **Feature flags via injected globals.** The `despia.postHogFlags` global holds all evaluated flag values, keyed by flag name, and updates in place whenever PostHog pushes new values. Reads are synchronous, always current, and require no `despia()` call.
* **Injected identity globals.** `despia.postHogDistinctId`, `despia.postHogSessionId`, and `despia.postHogOptedOut` are kept live on the `despia` object and update in place after every relevant scheme fires.
* **GDPR consent controls.** The `posthog://opt_out` and `posthog://opt_in` schemes act as the SDK-level consent cutoff. Events fired while opted out are dropped before they reach PostHog servers, and the cutoff persists across sessions.
* **Reset on logout.** The `posthog://reset` scheme clears the distinct ID, person properties, super properties, group associations, and session in a single call.
Full scheme list, parameters, and the standard auth flow are documented in the [PostHog reference](/analytics/posthog/reference).
**Packages:**
* [despia-native](https://www.npmjs.com/package/despia-native)
To enable the integration, paste your PostHog project API key into **Despia > App > Integrations > PostHog**, toggle the integration on, and rebuild. PostHog SDK initialization is compiled into the binary, so the rebuild is required before any scheme will fire.
## PK Pass / Wallet
The `wallet://pkpass` command is now available, letting your app present the native wallet sheet and add a `.pkpass` file to the user's device wallet.
**What ships today:**
* **Pass sheet trigger.** Call `despia('wallet://pkpass?url=...')` with a publicly hosted HTTPS URL pointing to a `.pkpass` file. The runtime fetches the file and presents the native add-to-wallet sheet.
* **Callback payloads.** Define `window.onWalletEvent` before the `despia()` call to receive the outcome. Payloads report `presented`, `dismissed`, and `failed` states, with typed `error` strings for download failures, invalid passes, and device restrictions.
Full documentation is available at [PK Pass](/wallet/pkpass)
**Packages:**
* [`despia-native`](https://www.npmjs.com/package/despia-native)
No dashboard configuration changes or rebuild steps are required to use this feature.
## Expanded Clerk Auth: SSO, MFA, password reset, SMS, magic links, and more
The native Clerk Auth bridge gains a wave of new sign-in methods and a handful of upgrades to existing flows. Everything works identically on iOS and Android.
**New sign-in methods:**
* **Enterprise SSO.** Route the user to their organization's SAML or OIDC identity provider based on email domain. Returns a normal sign-in or sign-up event depending on whether the user is new to the organization.
* **MFA continuation.** Continue a sign-in whose first-factor result came back needing a second factor. Supports TOTP and backup codes via single-call verify, plus a prepare step for SMS or email second factors.
* **Password reset.** Three-step reset flow: send a code, verify the code, set a new password. Works with both email and phone identifiers, the bridge picks the right delivery channel.
* **SMS one-time code.** Passwordless sign-in via a one-time code sent over SMS. Phone numbers are normalized to E.164, so human-formatted numbers like `+1 (555) 123-4567` are accepted directly.
* **Magic link tickets.** Native equivalent of an email magic link. Your backend mints a one-time ticket through Clerk's API and includes it in an email or push that deep-links into the app, the app forwards the ticket to the bridge to complete sign-in.
**Existing methods, more flexible:**
* **Username and phone sign-in.** Password sign-in now accepts any identifier: email, username, or phone. Pass `jane`, `jane@example.com`, or `+15551234567` and the bridge resolves it.
* **Sign-up without email.** Password sign-up no longer requires an email address. At least one of email, username, or phone is enough, alongside the password. Username-only or phone-only accounts are valid.
* **Email code sign-up.** The email code flow now supports new-account creation, not just sign-in. Optional first name, last name, and username can be passed when sending the code.
**Behavior upgrades to existing flows:**
* **Auth Sheet and User Profile emit close events.** The prebuilt sign-in sheet now fires a completion event when it closes, carrying `userId` and `emailAddress` if the user signed in, or a dismissed event if the sheet closed without sign-in. The User Profile sheet fires a closed event on dismiss. Apps no longer need to poll the session state after the sheet closes.
* **Shared pending-flow state.** Email code, phone code, password reset, and MFA continuation all hold one in-memory pending sign-in at a time. Starting a new flow cancels any other in flight, and calling a later step before its prerequisite returns a missing-state error.
* **Unified completion semantics.** Every flow that reaches a successful completion now follows the same pattern: the result payload carries `userId` (plus `emailAddress` if a primary email exists), the JWT refresher starts, attribution fires, and `window.clerkJWT` populates a tick later.
Full reference at [Auth Methods](https://docs.despia.com/authentication/clerk/auth-methods) and the [API Reference](/authentication/clerk/reference).
Clerk Auth remains opt-in. Apps already using the bridge get everything in this drop automatically as soon as the runtime updates. No dashboard configuration changes, native build settings, or rebuild steps are required.
## WebSockets
WebSocket connections can now be opened and managed through the native runtime, keeping them alive across WebView reloads, app backgrounding, and network loss.
**What ships today:**
* **Native connection ownership.** The connection lives on the native side rather than in the WebView. It persists through JavaScript suspension, app backgrounding, and WebView reloads without any additional handling in your code.
* **Durable inbound message storage.** Incoming messages are written to disk before your handler sees them. If the handler fails or the app restarts, Despia replays the message on the next resume, reconnect, or reload.
* **Durable outbound queue.** Sends issued while the socket is offline are saved and flushed in order once the connection is restored.
* **Auto-reconnect with backoff.** Dropped connections retry automatically with wait times that grow per attempt and cap at 30 seconds. A keepalive ping goes out every 25 seconds to prevent routers and proxies from closing idle sockets.
* **Subscribe frame registration.** The `websocket://subscribe` command registers a frame Despia sends to the server on every connect and reconnect, enabling server-side cursor replay for any messages missed during a gap.
* **Connection status.** The `websocket://status` command returns the live state of a connection, including pending outbound sends and unacknowledged inbound messages.
Full documentation is available at [WebSockets](/connectivity/websockets).
**Packages:**
* [despia-native](https://www.npmjs.com/package/despia-native)
Rebuild your app to pick up the new scheme handler. No dashboard configuration changes or integration code changes are required.
## Native file preview with `fileviewer://`
A new `fileviewer://` URL scheme opens any remote file in a native previewer on both platforms: QuickLook on iOS, and Despia's own in-app file viewer on Android. Files served behind your existing login work without extra token plumbing, since the download reuses the WebView's current cookies and User-Agent.
**What ships today:**
* **Custom Android previewer.** Android uses Despia's own in-app file preview engine, not the system "Open with" chooser. This removes the dependency on whichever third-party file viewer the user happens to have installed and delivers render fidelity on par with QuickLook on iOS.
* **True cross-platform parity.** The same `fileviewer://` call renders identically on iOS and Android. Your users see consistent output regardless of device, OS version, or what apps they have installed.
* **Authenticated downloads.** The native fetch forwards the WebView's cookie jar and User-Agent automatically, so session-protected file URLs render without query-string tokens or custom headers.
* **Broad type coverage.** PDFs, images (PNG, JPG, HEIC), plain text, RTF, CSV, and Office documents (DOCX, XLSX, PPTX) are supported. The file extension is taken from the URL path, with a fallback to the response `Content-Type`.
* **Optional theme parameter.** Pass `theme=light` or `theme=dark` alongside `src` to match the native previewer to your app's current appearance. Defaults to the system appearance when omitted.
See the [File Viewer documentation](/native-features/file-viewer) for the full API contract, the CDN-only URL requirement, and the upload-first pattern for canvas-exported or generated files.
Rebuild your app to pick up the new scheme handler. No dashboard configuration changes or integration code changes are required.
## Stripe Payment
Native Stripe PaymentSheet and CustomerSheet integration is now available through the `stripe://payment` and `stripe://manage` native feature commands. Apps can charge cards, Apple Pay, Google Pay, and Link through the native payment sheet, and let signed-in customers add, remove, and select among their saved cards through Stripe's native CustomerSheet, all without leaving the web app or building a native wrapper.
Both actions share a single fire-and-listen pattern. Your backend creates the Stripe objects, your page forwards the secrets into `despia('stripe://...')`, and the Despia runtime fires `window.stripeEvent` once with the outcome. Route on `event.method`: `paymentSheet` for charges, `customerSheet` for card management.
**What ships today:**
* **Native PaymentSheet.** Call `despia('stripe://payment?publishable_key=...&payment_intent_client_secret=...')` to open Stripe's native payment sheet on top of the WebView. The sheet handles card entry, 3DS, Apple Pay, Google Pay, Link, and any other method enabled on the Payment Intent.
* **Optional saved cards on payment.** Pass `customer_id` (`cus_...`) and `ephemeral_key_secret` (`ek_...`) on `stripe://payment` to attach the Stripe customer so the sheet lists their saved cards and saves the new one for reuse. The pair is all-or-nothing: pass both for saved cards, pass neither for guest checkout (the default and original behavior). Passing exactly one returns a `missing param` failed event with no sheet. Existing integrations without these params continue to work unchanged.
* **Native CustomerSheet for saved cards.** Call `despia('stripe://manage?publishable_key=...&customer_id=...&ephemeral_key_secret=...&setup_intent_client_secret=...')` to let the customer manage their saved payment methods. The ephemeral key must be created server-side with the Stripe API version the mobile SDK expects and is short-lived. The optional SetupIntent client secret enables the "add a new card" flow.
* **Theme.** `theme=light`, `theme=dark`, or `theme=automatic` controls the sheet's color scheme on both actions. Case-insensitive, unknown values fall back to `automatic` which follows the device's system setting.
* **Accent color.** `accent_color=` sets the primary Pay button color and the sheet's primary accent. Accepts 6-digit hex, 3-digit shorthand, 8-digit hex with alpha, with or without a leading `#`, and the percent-encoded `%23` form. The native runtime tolerates raw `#` characters in any parameter position. Invalid values are silently ignored.
* **Corner radius.** `corner_radius=` sets the general corner radius applied to input fields and buttons together, including the secondary back button. Non-negative numbers only. Invalid, negative, or non-finite values are silently ignored.
* **Action corner radius.** `action_corner_radius=` overrides the primary Pay button corner radius independently of the general radius. When omitted, the Pay button inherits from `corner_radius`. When set without `corner_radius`, only the Pay button is rounded and the rest of the sheet stays at Stripe's default.
* **Single shared callback.** `window.stripeEvent` receives results from both actions. Branch on `event.method` to route between `paymentSheet` and `customerSheet`, then branch on `event.status` within each. PaymentSheet statuses are `completed`, `canceled`, or `failed`. CustomerSheet statuses are `selected`, `canceled`, or `failed`, with no `completed` since managing cards is not a payment.
* **Stable error contract.** A missing or empty required param returns the literal error string `missing param` on either action, which is safe to match on. All other failures return Stripe's localized SDK message, which should be logged or displayed but never branched on since it is locale-dependent.
* **iOS and Android parity.** The same commands work identically on both platforms with no fork in your web code. The native parser treats `#` as an ordinary character rather than a fragment delimiter, so hex colors and any position of the `#` resolve the same way everywhere.
Stripe Payment is for physical goods and real-world services. Digital goods (credits, coins, subscriptions, memberships, premium tiers, ad removal, level unlocks) must continue to use [RevenueCat](/native-features/revenuecat/introduction), the store-compliant in-app purchase path backed by Apple StoreKit and Google Play Billing. The `stripe://manage` action governs only Stripe-stored cards used for direct Stripe charges, not App Store or Play Store subscriptions managed through RevenueCat. Surface "Manage cards" only on screens where Stripe is the payment rail.
Documentation: [Stripe Payment](/payments/stripe), [Manage saved cards](/payments/stripe#manage-saved-cards).
**Packages:** [despia-native](https://www.npmjs.com/package/despia-native).
Rebuild your app from the Despia dashboard to receive the new native runtime. No dashboard toggle is required to enable the feature. To integrate on the web side, define `window.stripeEvent` on your page before firing either action, route on `event.method`, and call `despia('stripe://payment?...')` or `despia('stripe://manage?...')` with the secrets your backend returned.
## Clerk Auth for Despia
Despia now ships an optional native Clerk authentication bridge for iOS and Android, driven entirely from JavaScript over a `clerk://` URL scheme. Apps that need Keychain or Keystore backed sessions, native Sign in with Apple, offline cold-launch, or zero-config server-side auth can opt in. Apps using web auth providers like Supabase, Firebase, NextAuth, Auth0, or Clerk's own web SDK are unaffected and continue to work out of the box.
The same `clerk://` URLs, event payloads, status strings, and error codes work identically on iOS and Android. Implementation differences (Apple Sign In sheet vs Custom Tab, Keychain vs Keystore) happen under the hood and never reach the JS layer.
**What ships today:**
* **Configure and the two globals.** `clerk://configure` wires up the bridge. The native side writes `window.clerkJWT` (auto-refreshed every 50 seconds, under Clerk's 60-second TTL) and fires every result through `window.onClerkEvent`. Same-key configure is a no-op, different keys switch tenant and clear the secure session store.
* **Prebuilt Auth Sheet.** `clerk://authview` opens Clerk's native sign-in sheet covering email/password, OAuth, magic links, MFA, password reset, and passkeys in one prebuilt UI.
* **Email and password.** `clerk://manual?method=password` drives manual sign-in and sign-up flows with your own forms while keeping the native session and JWT refresh.
* **OAuth providers.** `method=oauth` runs system-browser OAuth via `ASWebAuthenticationSession` on iOS and Chrome Custom Tabs on Android. Over 25 providers supported (Google, GitHub, Microsoft, Discord, LinkedIn, Facebook, Slack, Notion, Vercel, and more), with unknown values passed through as Clerk custom providers.
* **Native Sign in with Apple.** `method=apple` uses Apple's native `ASAuthorizationController` sheet on iOS and Apple OAuth via Chrome Custom Tabs on Android. Required by App Review for any iOS app offering third-party social login.
* **Email one-time code.** `method=email_code` drives the two-step OTP flow with `action=start` and `action=verify`.
* **SMS one-time code.** `method=phone_code` (alias `sms`) ships the same two-step OTP flow over SMS. Phone numbers are normalized to E.164, both code flows share pending state so only one runs at a time.
* **Passkeys.** `method=passkey` signs in with an existing credential or registers a new one to the signed-in account. Saved to iCloud Keychain on iOS or Google Password Manager on Android.
* **User Profile sheet.** `clerk://userprofile` opens Clerk's prebuilt account-management sheet for editing profile, managing emails, phones, external accounts, passkeys, sessions, MFA, and account deletion.
* **Offline session snapshot.** `clerk://state` reads the full user and session object from the in-memory cache hydrated from secure storage. No network round-trip, works with the device fully offline.
* **Zero-config server-side auth.** `clerk://ssr` writes the session into the WebView cookie store scoped to your app host plus `localhost` and `127.0.0.1`. Next.js `clerkMiddleware()`, TanStack, and any framework reading standard Clerk cookies sees an authenticated user on the very first request, no Clerk web SDK on the page required. A TTL ladder (50s refresher, 55s cookie, 60s JWT) guarantees the cookie pair is always in a state Clerk middleware accepts without a handshake redirect. An optional `header=` param also injects the JWT on main-frame navigations.
* **Attribution sync.** `clerk://attribution` auto-mirrors the Clerk userId into OneSignal and AppsFlyer on every sign-in or sign-up and clears it on sign-out. Enabled by default, overridable via the existing `setonesignalplayerid://` and `appsflyer://set_user_id` routes.
* **Sign out and multi-tenant switching.** `clerk://signout` revokes the session, stops the JWT refresher, clears attribution IDs, and writes the signed-out cookie state. Calling `configure` again with a different publishable key switches tenant and resets everything atomically.
Full documentation:
* [Introduction](/authentication/clerk/introduction)
* [Auth Methods](/authentication/clerk/auth-methods)
* [Sessions](/authentication/clerk/sessions)
* [Backend](/authentication/clerk/backend)
* [Account](/authentication/clerk/account)
* [API Reference](/authentication/clerk/reference)
Clerk Auth is opt-in. The bridge stays inert until your web app fires `clerk://configure`, so apps not using it see no change. Apps that want it need a publishable key from the [Clerk Dashboard](https://dashboard.clerk.com) and a call to `clerk://configure` on boot. No dashboard configuration changes, native build settings, or rebuild steps are required to access the bridge - it ships in every Despia runtime.
## Speech recognition
Despia now exposes the platform's native speech recognizer through two interoperable JavaScript surfaces, so your web app can transcribe audio on-device without a third-party STT service or paid API.
**What ships today:**
* **URL-scheme bridge.** A flat four-event control flow via `speechrecognition://start`, `stop`, and `abort`, with results delivered to a single `window.onSpeechRecognitionEvent` callback. Suited to push-to-talk UIs and explicit control flows.
* **Web Speech API polyfill.** A drop-in `window.SpeechRecognition` and `webkitSpeechRecognition` implementation that matches the standard browser surface. Existing Web Speech code and libraries like `react-speech-recognition` run unmodified inside your app.
* **Cross-platform parity.** Identical event shapes, error vocabulary, and parameter names on iOS and Android. The same JavaScript runs on both platforms with no branching. Android network failures are folded into the `audio-capture` error code so a single handler covers both runtimes.
* **Continuous and interim modes.** Long-form dictation with live partial transcripts, plus single-utterance push-to-talk, configurable per session via the `continuous` and `interim` query parameters.
* **BCP-47 language selection.** Pass any BCP-47 tag like `en-US`, `de-DE`, or `ja-JP`, or omit the language parameter to use the device system locale.
* **Custom vocabulary biasing.** Pass `known_words` as a comma-separated query parameter, or set `recognition.knownWords` on the polyfill, to nudge the recognizer toward product names, jargon, or proper nouns. Backed by `SFSpeechAudioBufferRecognitionRequest.contextualStrings` on iOS 10 and later and `EXTRA_BIASING_STRINGS` on Android 13 and later, with a graceful no-op on older Android.
Full documentation, including event ordering, the complete error code table, and end-to-end examples for push-to-talk and continuous dictation, is at [Speech recognition](/native-features/speech-recognition).
Rebuild your app from the Despia Editor to get the new bridge. Without a rebuild, `speechrecognition://` calls resolve silently and `window.SpeechRecognition` reads as undefined, since the native handler and the polyfill injection are both compiled into the signed binary.
## NFC tag reading and writing
Despia now exposes the device's NFC chip to your web app. Trigger a single-tap read or write through the `despia()` scheme, and handle the result through a global `window.onNFCEvent` callback.
**What ships today:**
* **Read mode.** `despia('nfc://read')` activates the system NFC sheet, scans one tag, and fires `window.onNFCEvent({ type: 'read', id, data })` with the tag's lowercase hex identifier and NDEF payload.
* **Write mode.** `despia('nfc://write?value=...')` writes an arbitrary payload to the next tapped tag and fires `window.onNFCEvent({ type: 'write' })` on success.
* **Cancellation and error handling.** The runtime distinguishes user-dismissed sessions (`type: 'dismissed'`) from platform failures (`type: 'error'`), so silent resets and retry prompts stay separate.
* **vCard payloads.** Write a contact card by prefixing the value with `VCARD_` and joining properties with underscores. The receiving phone recognises the vCard and offers to save the contact natively.
Available on all modern iPhones from XS onwards and most Android phones with NFC hardware. On devices without an NFC chip, scheme calls resolve silently.
Documentation: [NFC](/native-features/nfc).
**Packages:**
* [despia-native](https://www.npmjs.com/package/despia-native)
Setup is required. In Apple Developer, enable the **NFC Tag Reading** capability on your app's core bundle ID. In the Despia Editor, toggle **NFC** under **App > Addons**. Trigger a fresh native build to apply both changes. Without the rebuild, `nfc://read` and `nfc://write` resolve silently and `window.onNFCEvent` never fires.
## window\.virtual.href - 15 years later, a new plugin bridge
After 15 years of routing every plugin call through `window.location.href` URL redirects, the bridge finally has a successor: `window.virtual.href`. This is a transparent internal architecture change on both iOS and Android, scoped to the `window.despia` and `despia()` paths. No code changes, no rebuilds, no NPM updates, no dashboard configuration - nothing on the developer side moves. The `window.despia` proxy now forwards to `window.virtual.href` instead of `window.location.href`, which removes the platform webview's URL character limit and eliminates the dropped-call risk caused by the navigation queue collapsing back-to-back navigations.
**Direct `window.location.href` plugin calls are unaffected and stay fully supported.** Over 7,500 apps - including most projects built with Despia before 2023 - call `window.location.href = "myscheme://..."` directly rather than going through the `window.despia` abstraction. That path is now the legacy bridge, but legacy here only means "the older path" - it remains active, fully maintained, and will continue to receive new feature support and improvements over time. Nothing about it is deprecated, and no app on the direct path needs to migrate, now or later.
For 15 years, the `window.location.href` redirect was the only way native code got triggered from the webview. It worked, but it inherited the platform webview's URL character cap - roughly 2KB on iOS WKWebView and a similar practical ceiling on Android WebView. Large payloads such as base64-encoded images, long AI prompts, or JSON blobs were silently truncated or rejected before native code ever saw them. Firing multiple plugin calls back-to-back was also unreliable, because the navigation queue on both WebKit and Android WebView could collapse them and silently drop the second call. `window.virtual.href` removes both constraints.
**Why nothing needs to change in your code.** Every Despia NPM package and the `despia()` helper have always called `window.despia`, never `window.location.href` directly. `window.despia` is a proxy: it used to forward to `window.location.href`, and it now forwards to `window.virtual.href`. This migration was planned from day one. `window.despia` was built as a proxy specifically so this kind of switch could happen invisibly - and today it has. The `despia()` calls already in your code keep working unchanged on both iOS and Android.
**What ships today for `window.despia` and `despia()` users:**
* **No character limit.** Pass multi-megabyte payloads directly on iOS and Android. Full base64 images, long prompts, JSON dumps, and config blobs all go through intact.
* **No dropped calls.** Fire as many plugin calls in a row as you want, in any order. Every call runs on both platforms.
* **Free-form strings.** Spaces, special characters, and newlines pass through as-is with no manual encoding required.
* **Same syntax as before.** `window.virtual.href = "myscheme://..."` uses the same scheme and parameter shape as the redirect path it replaces. The string is delivered straight to native code via a message channel instead of going through the URL redirect machinery.
**Why the URL scheme syntax stays.** Despia plugin calls keep the `myscheme://method?param=value` shape on both bridges, and that is a deliberate choice rather than a legacy quirk. JSON-based bridges have their place, but URL schemes have real advantages worth keeping: one-line calls compose cleanly, they grep and search trivially across a codebase, and they read well to AI coding tools, which handle flat scannable text far better than nested object payloads. The single meaningful argument against the URL approach was always the 2KB cap. With `window.virtual.href`, that cap is gone - so the syntax keeps every advantage and loses its only real drawback.
**Who this affects:** developers using the `despia()` NPM helper or assigning to `window.despia` directly. They get the upgrade automatically on their next deployment.
**Who this does not affect:** developers calling `window.location.href = "myscheme://..."` directly. That bridge stays exactly as it was, with full support and continued investment in new features and improvements.
No dashboard configuration changes, rebuild steps, code changes, or NPM package updates are required. Existing iOS and Android apps both inherit the appropriate behaviour automatically on their next deployment.
## Action Sheet
A new `actionsheet://` scheme triggers a real platform action sheet over your web view, with a single global callback that fires when the user picks a row or dismisses the sheet.
**What ships today:**
* **Native iOS and Android sheets.** Pass a title and a JSON-encoded items array as URL params. The native side handles presentation and iPad anchoring to the centre of the WebView automatically.
* **Per-platform icons.** Each item accepts `iconIos` (any SF Symbol name) and `iconAndroid` (any Material drawable resource name). Missing or unknown icon names render the row without an icon, no crash.
* **Destructive styling.** Setting `destructive: true` on an item renders it in red, matching the platform cue users expect for irreversible actions like delete, remove, and leave.
* **Theme override.** Pass `theme: 'light'`, `'dark'`, or `'system'` to control sheet appearance independently of the operating system. Defaults to `system`.
* **Single global callback.** The native side calls `window.onSheetEvent(value)` once per sheet with the tapped item's value, or `null` if the user tapped Cancel, tapped outside, or tapped back.
Documentation: [Action Sheet](/native-features/action-sheet).
**Packages:**
* [despia-native](https://www.npmjs.com/package/despia-native)
This feature requires a native rebuild. Add the handlers in your web code, then rebuild your app from the dashboard for the feature to work.
## Focus events
Apps can now detect when they enter the foreground or background through global `window.focusin()` and `window.focusout()` callbacks. Attach handlers directly to the window object to react to lifecycle changes such as resuming sessions, refreshing data, or pausing media.
**What ships today:**
* **focusin callback.** Fires every time the app returns to the foreground, including the first launch resume. Use it to revalidate session state, refresh auth tokens, or reload content when the user comes back.
* **focusout callback.** Fires once per background transition. Use it to stop timers, pause video playback, or persist unsaved state before the app is suspended.
Detect the runtime with `navigator.userAgent.toLowerCase().includes('despia')` before assigning handlers to avoid running native-only code in browsers.
**Packages:**
* [despia-native](https://www.npmjs.com/package/despia-native)
This feature requires a native rebuild. Add the handlers in your web code, then rebuild your app from the dashboard for the callbacks to fire.
## Apple Health workouts with per-session statistics
A new `healthkit://workouts` scheme fetches workout sessions as a flat array and can attach aggregated quantity statistics computed across each individual workout's time range. The result lands on `window.healthkitWorkouts`, separate from the existing multi-type `healthkitResponse` object, so the new and legacy paths coexist in the same app.
**What ships today:**
* **Dedicated workouts endpoint.** Call `healthkit://workouts?days=N` to retrieve every workout in the last N days as a flat array, with `date`, `activityType`, `duration`, `calories`, `distance`, `value`, and `unit` on each record. Authorization for `HKWorkoutTypeIdentifier` is requested automatically on the first call.
* **Per-workout statistics.** Pass an `included=` parameter with a comma-separated list of `HKQuantityTypeIdentifier` values, each suffixed with `Average`, `Max`, `Min`, or `Sum`, to compute aggregates scoped to the workout's `startDate` to `endDate` window rather than the whole day. Results land on a `samples[]` array on each workout, with each entry exposing `key`, `value`, and `unit`.
* **Suffix-driven aggregation.** `Average` maps to discrete average, `Max` to discrete max, `Min` to discrete min, and `Sum` to cumulative sum. An identifier with no suffix defaults to discrete average. Authorization for every base quantity type referenced is requested in a single prompt, with the suffix stripped before the auth call. Invalid identifiers are silently skipped without affecting valid ones in the same call.
* **PascalCase activity types.** The new scheme returns Apple's `HKWorkoutActivityType` enum names directly, for example `Running`, `Cycling`, and `FunctionalStrengthTraining`. The legacy `readhealthkit://HKWorkoutTypeIdentifier` path continues to return lowercase values like `running`, so existing UIs reading from `healthkitResponse` are unaffected.
* **Fully backwards compatible.** Every existing `readhealthkit://` and `healthkit://read?types=...` call keeps working unchanged. Use `healthkit://workouts` for any workouts-only read or any read that needs per-workout statistics, and the legacy path when batching workouts with other unrelated identifiers in a single call.
Common combinations such as average and peak heart rate, summed active energy, and pace or power ranges are documented in the [Apple Health](/health-data/apple-health) page along with the new parameters and full response shape.
**Packages:**
* [despia-native](https://www.npmjs.com/package/despia-native)
Existing integrations require no code changes. To use the new scheme, call `healthkit://workouts` and read from `window.healthkitWorkouts`. If your app has not been rebuilt since the new endpoint shipped, trigger a fresh build from the Despia Editor to pick it up.
This feature requires a native rebuild. Add the handlers in your web code, then rebuild your app from the dashboard for the callbacks to fire.
## Gyroscope sensor
The gyroscope feature ships today, exposing live angular velocity readings alongside magnetic compass heading on a single channel through `gyroscope://start` and `gyroscope://stop`. Every gyro sample carries the latest cached heading, so one callback drives motion-based UI, shake gestures, AR overlays, fitness apps, and compass or Qibla needles.
**What ships today:**
* **Single-channel gyro and compass.** `window.onGyroscopeChange` receives `x`, `y`, `z` angular velocity in radians per second plus `heading`, `headingAccuracy`, and `timestamp` on every emitted reading. Heading is `-1` until the magnetometer produces its first fix.
* **Magnitude threshold.** `gyroscope://start?threshold=` filters readings natively against `√(x² + y² + z²)`. Pass `0` to receive every sample for tilt or parallax UI, higher values for shake-style gestures so the JavaScript layer only sees deliberate motion.
* **Tracking state flag.** `despia.gyroscopeActive` reflects whether the sensor is running and survives soft close and reopen. Boot code can branch on the flag to resume the live readout or show the start button without guessing.
* **Resume after soft close.** When the user backgrounds and reopens the app, the runtime restores the session using the last threshold and continues invoking `window.onGyroscopeChange` as soon as the handler is reattached.
* **Calibration prompts.** Readings carry a `calibration_required` status when iOS reports the magnetometer needs recalibrating. Heading continues to stream during this state so the page can surface a figure-8 hint and dismiss it when `headingAccuracy` returns to a good range.
* **Error handling.** Devices without a gyroscope or magnetometer emit a `status: "error"` payload with `error: "unavailable"`, and `despia.gyroscopeActive` stays `false` so fallback UI can take over.
* **No permission prompt.** Heading is delivered without requesting location access. True-north and Qibla conversions can be done in JavaScript using the page's own coordinates and a local declination value.
Full documentation is available at [Gyroscope](/native-features/gyroscope).
**Packages:**
* [despia-native](https://www.npmjs.com/package/despia-native)
No dashboard configuration changes are required. Install or update `despia-native`, define `window.onGyroscopeChange`, and call `gyroscope://start` to begin receiving readings.
This feature requires a native rebuild. Add the handlers in your web code, then rebuild your app from the dashboard for the callbacks to fire.
## Screen Radius
The runtime now measures the device's hardware screen corner radius at page load and exposes it to the web layer, so modals, sheets, cards, and overlays can curve in lockstep with the physical bezel.
**What ships today:**
* **Automatic injection.** No scheme call is required. The runtime reads the top-left corner radius on every supported device and writes it to the page before stylesheets and scripts run.
* **JavaScript value.** `despia.screenRadius` returns the radius as a number in CSS pixels, for example `47.33` on iPhone 14 Pro. Use it for any sizing logic that needs the raw measurement.
* **CSS variable.** `--screen-radius` is scoped to the document root with a `px` unit, ready to drop into `border-radius` rules or `calc()` expressions for concentric inner corners.
* **Graceful fallback.** The value resolves to `0` on devices without rounded screens, on older OS versions, and in non-Despia environments. Pair every `var(--screen-radius)` with a `0px` default so `calc()` expressions stay valid in desktop previews and clamp to square corners.
* **Cross-platform parity.** iOS and Android expose identical values in CSS pixels, so feature code does not need to branch by platform.
Full layout patterns for inset modals, edge-pinned sheets, and combinations with safe area insets are documented in the [screen radius reference](/native-features/screen-radius).
**Packages:**
* [despia-native](https://www.npmjs.com/package/despia-native) on npm
This feature requires a native rebuild. Add the handlers in your web code, then rebuild your app from the dashboard for the callbacks to fire.
## RevenueCat Customer Center
A new `revenuecat://center` scheme opens the native RevenueCat Customer Center sheet, giving users a single in-app surface to restore purchases, manage their subscription, request refunds, and complete feedback surveys without leaving the app.
**What ships today:**
* **Customer Center scheme.** Call the new `revenuecat://center` scheme with an `external_id` parameter to present the native sheet. Layout, copy, and available actions are configured from the RevenueCat dashboard, so no client-side UI work is required.
* **Event callback.** Every interaction inside the sheet streams back through `window.onRevenueCatCenter(event)`. Switch on `event.event` to handle `restoreStarted`, `restoreCompleted`, `restoreFailed`, `manageSubscriptionsOpened`, `refundRequested`, `refundCompleted`, `feedbackSurveyCompleted`, `managementOptionSelected`, and `dismissed`.
* **iOS in-app refunds.** iOS users can now initiate refund requests directly from the sheet. The runtime emits `refundRequested` and `refundCompleted` events with `productId` and a `status` field of `success`, `userCancelled`, or `error`.
* **Android refund fallback.** Google Play does not allow in-app refund requests, so configure a `customUrl` management option in the RevenueCat dashboard and route Android users to a `mailto:` link or your support page from the `managementOptionSelected` event. iOS continues to use the native flow.
* **Fully safe restore pattern.** On `restoreCompleted`, `refundCompleted`, and `dismissed`, re-query the native store with the existing `getpurchasehistory://` scheme and re-run your entitlement check. The event payload's `activeEntitlements` array is informational, the native store query is authoritative.
Full implementation details, event field reference, and Android refund routing examples are documented in the [in-app purchases reference](/docs/in-app-purchases/reference).
**Packages:**
* [despia-native](https://www.npmjs.com/package/despia-native) on npm
No new rebuild is required for apps already on a recent Despia runtime. Configure the Customer Center appearance and any custom management URLs in your RevenueCat dashboard, then call the scheme from your web layer.
## Extended HealthKit sleep identifier support
HealthKit sleep reads now cover the full set of sleep analysis categories, so apps can ingest data from any HealthKit-compatible device and not just Apple Watch or iPhone sleep tracking.
Previously, sleep reads were limited to the basic in-bed and asleep categories produced by first-party Apple sleep tracking. Third-party rings, bands, and mattress sensors that sync richer stage data into the Health app had their detailed values dropped at the read layer.
**What ships today:**
* **Full stage breakdown.** Reads now return REM, core, deep, awake, and unspecified asleep values when the source device provides them, in addition to in-bed and asleep totals.
* **Third-party device coverage.** Any HealthKit-authorised device that writes sleep samples to the Health app is now readable through the same `despia()` sleep call, including Oura, Whoop, Withings, Eight Sleep, Fitbit, and Garmin where the user has enabled Health app sync.
* **Per-sample source metadata.** Each returned sample includes the originating source name and bundle identifier so the app can distinguish between devices when the user has more than one sleep tracker connected.
* **Backwards compatibility.** Existing integrations that only consume in-bed and asleep totals continue to work without changes. New stage fields are additive.
HealthKit permission prompts and Info.plist usage strings remain unchanged. Users who previously granted sleep read permission do not need to re-authorise.
**Example lookup:**
```js theme={null}
if (isDespiaIOS) {
const data = await despia('readhealthkit://HKCategoryTypeIdentifierSleepAnalysis?days=7', ['healthkitResponse'])
const sleep = data.healthkitResponse.HKCategoryTypeIdentifierSleepAnalysis
}
```
Each record returns `startDate`, `endDate`, `value`, and `label`, where `label` is one of `inBed`, `awake`, `asleep`, `core`, `deep`, or `rem`. See [Apple Health - Sleep data](/health-data/apple-health#sleep-data) for the full response schema.
No dashboard configuration changes, rebuild steps, or integration code changes are required. The expanded data is returned automatically on the next read.
## Updated NPM package documentation
The [despia-native](https://www.npmjs.com/package/despia-native) npm package page now links to the new documentation at [setup.despia.com](https://setup.despia.com), replacing the legacy npm.despia.com content.
No integration changes are required. The SDK API is unchanged.
## Critical alerts
Despia now supports critical alerts, allowing notifications to break through Do Not Disturb and silent mode on both Android and iOS.
**What ships today:**
* **Android.** Critical alerts work out of the box with no additional configuration required.
* **iOS.** Critical alerts require the Critical Alerts entitlement from Apple. Once granted, enable them in the Despia dashboard under App - Integrations - OneSignal - Critical Alerts, then rebuild a new version in Despia to activate the capability.
Android apps require no additional steps. iOS apps require the Apple entitlement and a dashboard configuration change followed by a rebuild.
## PowerSync schema configuration
`db.connect()` now accepts a `schema` parameter that tells the sync engine which tables and columns to map from your backend. Previously, schema configuration had to be inferred or handled separately outside the connect call.
**What ships today:**
* **Schema definition in connect.** Pass a `schema` object directly to `db.connect()` alongside `fetchToken` and `url`. Each key is a table name, each value declares the columns and optional indexes for that table.
* **Column type mapping.** Columns are typed as `'text'`, `'integer'`, or `'real'`, mapping directly to SQLite affinity types.
* **Optional indexes.** Each table accepts an optional `indexes` map of index name to an array of column names, allowing the sync engine to optimise queries at the schema level.
* **Updated TypeScript types.** `ConnectOptions` now includes `schema: PowerSyncSchema` as a required field. The `PowerSyncSchema` type is exported directly from `@despia/powersync`.
```typescript theme={null}
await db.connect({
fetchToken: async () => {
const res = await fetch('/api/powersync-token')
const { token } = await res.json()
return token
},
url: 'https://YOUR_POWERSYNC_INSTANCE',
schema: {
users: {
columns: {
id: 'integer',
email: 'text',
},
},
todos: {
columns: {
id: 'integer',
title: 'text',
done: 'integer',
},
indexes: {
byDone: ['done'],
},
},
},
})
```
Full documentation is available in the [PowerSync introduction](/local-database/introduction) and [API reference](/local-database/reference).
**Packages:**
* [`@despia/powersync@1.1.0`](https://www.npmjs.com/package/@despia/powersync)
Update the package to `1.1.0` and add a `schema` object to any existing `db.connect()` calls. No dashboard or backend changes are required.
## Apple Health realtime observer
HealthKit now supports live data observation. Subscribe to any health or workout identifier and receive a webhook POST to your server whenever the data changes, with background delivery that persists across app restarts.
**What ships today:**
* **Observer registration.** Call `healthkit://observe` with a comma-separated list of identifiers, a delivery frequency, and a server URL. Despia registers a native `HKObserverQuery` for each type and enables background delivery via iOS background task APIs.
* **Webhook delivery.** On every HealthKit update, Despia fetches the latest day of data for the changed type and POSTs it to your server as JSON with `event`, `userId`, `timestamp`, and `data` fields.
* **Configurable frequency.** Set `frequency` to `immediate`, `hourly`, `daily`, or `weekly` to control how aggressively the OS wakes the app to deliver updates.
* **Observer persistence.** Active observers are saved to device storage and restored automatically when the app restarts - no re-registration required.
* **Observer state.** `despia.observingHealthKit` reflects the current array of active identifier strings. It updates after every `observe` or `unobserve` call.
* **Selective stop.** Call `healthkit://unobserve?types=all` to stop all observers, or pass specific identifiers to stop individual types.
* **User identification.** Append your own user ID as a query parameter on the `server` URL (e.g. `?user=abc123`) to route webhook events directly to the right user record on your server without device ID mapping.
See the [Apple Health documentation](/health-data/apple-health) for the full API reference, webhook payload shape, and code examples.
No dashboard configuration changes or rebuild steps are required. Update `despia-native` to the latest version to access the new scheme.
**Packages:**
* [despia-native](https://www.npmjs.com/package/despia-native)
## Movement-based location tracking
The `location://` scheme now accepts a `movement` parameter that fires an additional GPS update whenever the device moves a set distance, independent of the time-based `buffer` interval.
**What changes:**
* **Distance-triggered updates.** Set `movement` to a distance in centimetres to receive a GPS point every time the device crosses that threshold. Use `movement=100` for 1-metre precision, suited to running, cycling, and navigation use cases.
* **Combined buffer and movement.** `buffer` and `movement` run simultaneously. `movement` drives updates as the user moves, and `buffer` acts as a heartbeat fallback when the device is stationary or movement updates are sparse.
* **Same location object shape.** Movement-triggered points are delivered to `window.onLocationChange`, stored in the local session, and POSTed to the `server` endpoint using the same object shape as time-based points. No handling changes are required.
To enable high-accuracy tracking, pass both parameters:
```js theme={null}
despia("location://?buffer=60&movement=100")
```
Filter for precise fixes using `horizontalAccuracy` before calculating distances - discard any point where the value exceeds `10`.
No dashboard configuration changes are required for foreground use. Background tracking requires the Background Location addon to be enabled in your Despia dashboard.
## HealthKit workout quantity identifiers
Despia HealthKit now supports workout identifiers that provide quantity data, allowing apps to read and write typed quantity samples associated with workout activity types.
**What ships today:**
* **Workout quantity identifiers.** Apps can now reference quantity-type identifiers tied to specific workout activities, such as active energy burned, distance, step count, and other measurable outputs produced during a workout session.
* **Quantity type resolution.** The SDK resolves each workout identifier to its underlying `HKQuantityType` automatically, so apps receive correctly typed samples without manual type mapping.
No dashboard configuration changes or integration code changes are required to enable this. Rebuild your app to pick up the updated HealthKit identifier support.
## Apple Sign In
Despia now supports Sign In with Apple using the Apple JS SDK across iOS, Android, and web, with platform-aware behaviour and full documentation.
**What ships today:**
* **iOS native - native Face ID sheet.** On iOS, the Apple JS SDK with `usePopup: true` opens the native Face ID / Apple ID sheet directly inside WKWebView. No `oauth://` bridge is needed on iOS. The `id_token` is returned to your JavaScript callback with no page redirect, which avoids the blank white screen that causes App Store rejection when using a redirect-based flow.
* **Android native - oauth:// bridge.** On Android, the `oauth://` bridge opens Chrome Custom Tabs for the Apple OAuth flow. `public/native-callback.html` receives the `id_token` and `code` in the URL hash and fires the deeplink to close the tab. The `oauth/` prefix in the deeplink is required.
* **Web - Apple JS SDK popup.** On web, the Apple JS SDK popup returns the `id_token` directly to your JavaScript callback. No redirect flow is used.
* **Two response mode options for Android.** The `fragment` mode redirects the browser directly to `native-callback.html` with `#id_token` in the hash - no backend POST handler needed. The `form_post` mode sends `code`, `id_token`, `state`, and `user` (name and email JSON, first login only) to your backend, which validates and redirects to `native-callback.html` with a session token.
* **Plain HTML callback file.** `public/native-callback.html` is recommended over a React component for the callback page. React Router can strip the `#id_token` hash fragment on route change, causing tokens to disappear. The plain HTML file bypasses React Router entirely and reads the hash directly from the browser. Chrome Custom Tabs hides the URL bar, so the `.html` extension is never visible to users.
* **React, Vue, Vanilla JS, and HTML auth page examples.** All auth page examples include the `searchParams` dependency array fix to handle the already-mounted page problem, where the deeplink updates the URL without remounting the component and the token handler never re-runs.
Apple Sign In requires a Services ID configured in the Apple Developer Console with your domain and return URL registered. The client secret JWT generated from your `.p8` private key expires after 6 months - set a reminder to regenerate it before expiry or Apple Sign In will silently stop working.
Full documentation is available at [Apple Sign In](/native-features/oauth/apple)
**Packages:**
* [despia-native](https://www.npmjs.com/package/despia-native)
A rebuild is required. No dashboard configuration changes are required beyond enabling the Apple JS SDK script tag and configuring your Apple Services ID credentials in your backend auth provider.
## AppsFlyer integration
Despia now includes a full AppsFlyer integration for install attribution, deep linking, in-app event tracking, and creator affiliate link support on both iOS and Android.
**What ships today:**
* **Install attribution.** The native AppsFlyer SDK is initialised automatically at launch on both iOS and Android. Attribution data is captured at install time, cached on-device, and injected into your web layer on every page load via `despia.appsFlyerAttribution`, `despia.appsFlyerReferrer`, and `despia.appsFlyerUID`. No setup code required.
* **Normalised referrer string.** `despia.appsFlyerReferrer` provides a clean source string on every load - values like `tiktok_ad`, `facebook_organic`, `google_ad`, and `organic` - ready to use for onboarding personalisation and funnel branching without any parsing.
* **Deep linking.** When a user opens the app via a campaign or creator OneLink URL, Despia resolves the deep link natively and navigates the WebView to the correct path automatically. `deep_link_value` maps directly to a route in your web app. No navigation code required from the web layer for fresh launches.
* **Re-engagement callback.** For users who already have the app installed and tap a campaign link while the app is open, Despia calls `window.onAppsFlyerDeepLink(data)` with the full click event payload so your app can respond without a full page reload.
* **Creator and affiliate links.** Deep link params support `deep_link_sub1` through `deep_link_sub5` for creator codes and affiliate IDs. Each creator can receive a unique OneLink URL. Attribution, commission tracking, and welcome personalisation are available from the same payload.
* **Event bridge.** In-app events are forwarded from your web layer to the native AppsFlyer SDK via `despia("appsflyer://log_event?...")`. Standard `af_` prefixed events map automatically to Meta and TikTok conversion events in their dashboards. Custom events appear in the AppsFlyer dashboard for funnel and retention analysis.
* **User identification.** `set_user_id`, `set_email`, and `set_phone` are all supported. Emails and phone numbers are hashed with SHA256 automatically before transmission.
* **GDPR consent.** `set_consent` accepts `is_gdpr` and `has_consent` params and passes them directly to the AppsFlyer SDK consent API.
* **On-demand data retrieval.** Both `get_uid` and `get_attribution` support an await pattern - `await despia("appsflyer://get_uid", ["appsFlyerUID"])` - for cases where you need the value immediately in the same execution flow.
* **Ad revenue tracking.** Coming soon - `despia("appsflyer://log_ad_revenue?...")` will allow impression-level revenue reporting back to AppsFlyer from Meta, TikTok, and AdMob to enable LTV and ROAS reporting per acquisition channel.
Configuration is handled entirely in the Despia dashboard with no native code changes required. Go to **Despia > App > Settings > Integrations > AppsFlyer** to enter your dev key, Apple App ID, OneLink URLs, and ad platform credentials. Despia configures Universal Links on iOS and App Links on Android automatically from the values you enter.
Full documentation is available at [AppsFlyer](/analytics/appsflyer).
**Packages:**
* [despia-native](https://www.npmjs.com/package/despia-native)
A rebuild is required after enabling the integration in the dashboard. No other integration code changes are required beyond calling the bridge methods documented above.
## Local CDN - query all cached files
A new `localcdn://query` method is available on the Local CDN API, returning every cached file in a single call without requiring individual IDs.
Previously, retrieving cached file metadata required passing a known list of index IDs to `localcdn://read`. There was no way to get a full inventory of the cache without tracking IDs separately in application state.
**What ships today:**
* **localcdn://query.** Calling `await despia('localcdn://query', ['cdnItems'])` returns the full `cdnItems` array containing every file currently in the local cache, across all folders.
* **Same response schema.** Each item in the returned array follows the existing response schema - `index`, `index_full`, `local_cdn`, `local_path`, `cdn`, `size`, `status`, and `created_at` - identical to `localcdn://read` output.
Full reference documentation is available on the [Local CDN reference page](/local-cdn/reference).
No dashboard configuration changes or rebuild steps are required. Update `despia-native` to the latest version to access the new method.
## Local server
Despia now ships a local server for iOS and Android. Your web build downloads to the device on first launch and is served from an on-device HTTP server at `http://localhost`. From that point on, the app boots with zero network latency and works completely offline.
**What ships today:**
* **On-device HTTP server.** Assets are served from `http://localhost`, not `file://` or a custom scheme. BrowserRouter, Vue Router, and any routing library that expects a real HTTP origin work without modification.
* **First-launch hydration.** On first open, Despia fetches your web build from your existing hosting (Netlify, Vercel, AWS, or anything else) and caches it on the device. No migration required.
* **OTA updates.** On startup, Despia fetches `despia/local.json` and compares the `deployed_at` timestamp with the cached value. If it has changed, the new build downloads in the background and applies on next launch. No app store review needed for HTML, CSS, JavaScript, image, or font changes.
* **`@despia/local` build plugin.** Available for Vite, Webpack, Rollup, Nuxt, SvelteKit, Astro, Remix, esbuild, and any build system via a `postbuild` CLI hook. The plugin scans your output directory after each build and generates `despia/local.json` automatically.
* **`despia-version-guard`.** A companion package for gating web UI features behind a minimum native runtime version. Supports React, Vue, Angular, Svelte, and Vanilla JS.
**Store compliance.** The local server downloads web content only (HTML, CSS, JS, images, fonts). No native code or executables are downloaded after submission. The approach follows patterns established by Expo and is compliant with Apple App Store Guideline 3.3.2 and the Google Play Malicious Behavior Policy.
**This feature is in final beta.** To request access, email [offlinemode@despia.com](mailto:offlinemode@despia.com).
**Documentation:** [Local Server Introduction](/local-server/introduction) and [Reference](/local-server/reference).
**Packages:**
* [`@despia/local`](https://www.npmjs.com/package/@despia/local) — build plugin
* [`despia-native`](https://www.npmjs.com/package/despia-native) — native SDK and JavaScript bridge
# NFC
Source: https://setup.despia.com/connectivity/nfc
Read from and write to NFC tags directly from your web app.
Communicate with physical NFC tags from your web app. The runtime activates the device's NFC chip in either read or write mode, and the next tag the user taps against the phone is processed.
The device must have NFC hardware. All modern iPhones from XS onwards and most Android phones support NFC, but some budget Android models do not. On devices without NFC, the call resolves silently.
***
## 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}
```
***
## Setup
NFC on iOS is a capability that has to be enabled on the App ID itself before the runtime can access the NFC chip. Enable it in Apple Developer, toggle the addon in the Despia Editor, then rebuild.
Sign in at [developer.apple.com](https://developer.apple.com) and go to **Certificates, Identifiers & Profiles > Identifiers**.
Select the App ID that matches your app's core bundle ID (the one Despia builds against).
Under **Capabilities**, tick **NFC Tag Reading** and save. Apple regenerates the provisioning profile with the new entitlement.
Go to **App > Addons** and toggle **NFC** on.
Trigger a new build from the Despia Editor. The new binary picks up the updated provisioning profile and the addon flag.
If you skip the rebuild, `nfc://read` and `nfc://write` resolve silently. No system NFC sheet appears, `window.onNFCEvent` never fires, and the call looks identical to running on a device without NFC hardware. Any change to the App ID capabilities or the Despia Editor addon list requires a fresh build to take effect.
***
## How it works
NFC has two modes, read and write. Each mode is triggered by its own scheme call, and results flow back through a global event callback on `window`.
```javascript theme={null}
const isDespia = navigator.userAgent.toLowerCase().includes('despia')
// to read
if (isDespia) despia('nfc://read')
// to write
if (isDespia) despia(`nfc://write?value=${encodeURIComponent('hello')}`)
```
Each call is one-shot. The runtime enters the requested mode, processes a single tag, then exits. Trigger them independently from your own UI handlers, never both in the same gesture.
***
## Handling NFC events
Define `window.onNFCEvent` once at app startup. The runtime invokes it with a single object whenever something happens during an active NFC session, whether a tag is read, a write completes, the user cancels, or anything goes wrong.
```javascript theme={null}
window.onNFCEvent = function (event) {
switch (event.type) {
case 'read':
console.log('tag id:', event.id)
console.log('payload:', event.data)
break
case 'write':
console.log('write successful')
break
case 'dismissed':
console.log('user cancelled the scan')
break
case 'error':
console.error('NFC error:', event.error)
break
}
}
```
The four event types and their fields:
Fires when a tag is successfully scanned in read mode. Carries `id`, the tag's unique identifier as a lowercase hex string (empty string if the device cannot read it), and `data`, the NDEF payload content as a string.
Fires when a write completes successfully. No additional fields.
Fires when the user closes the system NFC sheet before a tag is scanned. No additional fields.
Fires on any other failure, read or write. Carries `error`, a human-readable message describing what went wrong.
A successful read or write does not also emit `dismissed` or `error`, the in-flight action is cleared as soon as the success event fires. You can treat the four event types as mutually exclusive per session.
***
## Reading NFC tags
Call `nfc://read` to enter read mode. When the user taps a tag, the runtime fires `window.onNFCEvent({ type: 'read', id, data })` with the tag identifier and the NDEF payload.
```javascript theme={null}
const isDespia = navigator.userAgent.toLowerCase().includes('despia')
window.onNFCEvent = function (event) {
if (event.type === 'read') {
console.log('tag id:', event.id)
console.log('payload:', event.data)
}
}
```
Trigger the read from any UI element:
```html theme={null}
```
***
## Writing to NFC tags
Pass the value to write as the `value` query parameter. The runtime enters write mode, the next tag the user taps receives the payload, and `window.onNFCEvent({ type: 'write' })` fires when the write completes.
```javascript theme={null}
const isDespia = navigator.userAgent.toLowerCase().includes('despia')
window.onNFCEvent = function (event) {
if (event.type === 'write') {
console.log('write successful')
}
}
const value = 'https://example.com'
if (isDespia) despia(`nfc://write?value=${encodeURIComponent(value)}`)
```
Always wrap the value in `encodeURIComponent`. Without it, URLs with `?`, `&`, or `=` inside the payload will break the scheme parse on the native side.
Trigger the write from any UI element:
```html theme={null}
```
***
## Handling cancellation and failure
The runtime distinguishes between the user closing the NFC sheet and an actual failure. A `dismissed` event fires when the user backs out before a tag is scanned, and is not an error condition. An `error` event fires for everything else, including malformed tags, tags that are not NDEF formatted, write failures, and session timeouts.
```javascript theme={null}
window.onNFCEvent = function (event) {
if (event.type === 'dismissed') {
// user closed the sheet, reset UI without surfacing a failure
resetScanButton()
return
}
if (event.type === 'error') {
// surface a retry prompt, log the underlying message
showToast(`NFC failed: ${event.error}`)
}
}
```
The `error` message is a passthrough of the underlying platform error string, suitable for logging or developer-facing diagnostics. For end-user messaging, map the type to your own copy rather than displaying the raw string.
***
## Writing a vCard
To write a contact card that a phone can save directly from a tap, format the payload as a vCard string prefixed with `VCARD_` and join the properties with underscores.
```javascript theme={null}
const contact = {
name: 'John Doe',
title: 'Developer',
phone: '0452641211'
}
const vcard = `VCARD_FN:${contact.name}_Title:${contact.title}_TEL;TYPE=CELL:${contact.phone}`
if (isDespia) despia(`nfc://write?value=${encodeURIComponent(vcard)}`)
```
Any standard vCard property works (`FN`, `ORG`, `EMAIL`, `URL`, `ADR`, `TEL`, and the rest). When another phone reads the tag, the OS recognises the vCard and offers to save the contact natively.
***
## Resources
despia-native
[support@despia.com](mailto:support@despia.com)
# WebSockets
Source: https://setup.despia.com/connectivity/websockets
WebSocket connection owned by the native runtime, with durable message delivery across WebView reloads, suspension, and relaunch.
Open and manage WebSocket connections through the native runtime rather than the WebView. Connections stay alive across reloads, backgrounding, and network loss, with durable storage for both inbound messages and outbound sends.
The same message can arrive more than once. Always check `message_id` in your handler and skip any message you have already processed. Mobile platforms do not keep a socket alive indefinitely in the background, so include a cursor or last-seen id in your subscribe frame and let the server replay anything missed during the gap.
***
## 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
Two pieces are required: open a socket with `websocket://connect`, then assign `window.onWebSocketEvent` to receive all events that connection produces. The `id` parameter is your label for the connection. Every event carries it back, so one handler can route between multiple open sockets. The handler can be assigned before or after `connect`. Despia buffers events until the handler is present and flushes them once it is.
```javascript theme={null}
const isDespia = navigator.userAgent.toLowerCase().includes('despia')
window.onWebSocketEvent = function (evt) {
if (evt.type === 'message') {
console.log(`[${evt.id}]`, evt.payload)
}
}
if (isDespia) {
const url = encodeURIComponent('wss://example.com/ws')
despia(`websocket://connect?id=orders&url=${url}`)
}
```
***
## Handling events
Define `window.onWebSocketEvent` once. Despia calls it for every event on every open connection: incoming messages, reconnects, network drops, and command replies.
```javascript theme={null}
window.onWebSocketEvent = function (evt) {
switch (evt.type) {
case 'open':
console.log(`[${evt.id}] connected`)
break
case 'message':
return handleBusinessEvent(evt)
case 'reconnecting':
console.warn(`[${evt.id}] retry #${evt.attempt} in ${evt.delay.toFixed(1)}s`)
break
case 'closed':
console.warn(`[${evt.id}] closed code=${evt.code}`)
break
case 'dropped':
resync(evt.id)
break
case 'send_failed':
console.error(`[${evt.id}] outbound ${evt.oid} permanently failed`)
break
}
}
```
Every event carries `id` (the connection label) and `ts` (epoch seconds). The nine event types:
The socket is opening. Carries `attempt`, which is `0` on the first connect and the retry number on each subsequent reconnect.
The socket is ready. Carries `protocol`, the subprotocol the server agreed to, or an empty string.
An incoming frame. Carries `message_id` (unique per connection), `dataType` (`json`, `text`, or `binary`), and `payload`. This is the only event type that requires an acknowledgement.
The socket dropped and Despia is waiting before retrying. Carries `attempt` (the retry count) and `delay` (seconds until the next attempt). Wait time grows with each retry, capped at 30 seconds.
The socket closed. Carries `code` (WebSocket close code), `reason` (server close reason, often empty), and `clean` (`true` only for clean closes with code 1000).
A transient socket error. A `closed` and `reconnecting` event typically follow, so most applications only need to react to `closed`. Carries `error`, a string suitable for logging.
A reply to any command that included a `rid`. Carries `rid` (echoed back), `ok`, and `error` if something failed, plus any extra fields the command returns.
Despia discarded undelivered messages from the durable store, either because they are older than 7 days or because more than 5000 accumulated. Carries `count` and `reason`. Re-register your subscribe frame so the server can replay the gap.
A single outbound send failed 25 consecutive times and Despia gave up on it. Carries `oid`, the id Despia assigned to that send. The remainder of the queue continues.
***
## Opening a connection
`websocket://connect` opens or re-attaches to a connection. `id` and `url` are required. `headers` accepts a JSON-stringified object for handshake headers such as Bearer tokens. `protocols` accepts a comma-separated subprotocol list. `reconnect` defaults to `true`.
```javascript theme={null}
if (isDespia) {
const url = encodeURIComponent('wss://example.com/ws') // Needs to be valid remote server - no localhost
const headers = encodeURIComponent(JSON.stringify({ Authorization: 'Bearer ' + token }))
const protocols = encodeURIComponent('graphql-transport-ws,graphql-ws')
despia(`websocket://connect?id=orders&url=${url}&headers=${headers}&protocols=${protocols}`)
}
```
Calling `connect` on a connection that is already open is a no-op. To point an id at a different URL, call `disconnect` first, then call `connect` with the new URL.
Once the socket is open, Despia maintains it automatically. Drops trigger auto-reconnect with exponential backoff capped at 30 seconds. A keepalive ping goes out every 25 seconds to prevent routers and proxies from closing idle sockets. When the device regains network access, the reconnect fires immediately rather than waiting on a timeout. Pass `reconnect=false` only when the connection should terminate on the first drop.
***
## Receiving messages
Every incoming frame arrives as a `message` event. The shape of `payload` depends on `dataType`.
If the server sent a text frame whose body is a JSON object or array, Despia parses it and delivers `dataType: "json"` with `payload` as the parsed value. Do not call `JSON.parse` on it again.
```json theme={null}
{
"id": "orders",
"type": "message",
"ts": 1747600042,
"message_id": "orders:128",
"dataType": "json",
"payload": {
"orderId": "ord_abc",
"status": "filled"
}
}
```
Plain text frames and bare JSON scalars (`42`, `"hi"`, `true`, `null`) arrive as `dataType: "text"`. Scalars remain as text so an intended string `"42"` is never confused with the number `42`.
```json theme={null}
{
"id": "orders",
"type": "message",
"ts": 1747600043,
"message_id": "orders:129",
"dataType": "text",
"payload": "pong"
}
```
Binary frames are base64-encoded into `payload` with `dataType: "binary"`. Decode with `atob` and a `Uint8Array` before use.
```json theme={null}
{
"id": "orders",
"type": "message",
"ts": 1747600044,
"message_id": "orders:130",
"dataType": "binary",
"payload": "SGVsbG8gd29ybGQ="
}
```
***
## Acknowledging messages
Despia determines whether a message was handled by inspecting the return value of `window.onWebSocketEvent`. Returning anything other than `false` acknowledges the message, removes it from the durable store, and marks it complete. Returning `false`, throwing, or rejecting a returned Promise causes Despia to replay the message later.
Replay occurs on WebView reload, app resume, socket reconnect, and network restoration. Always check `message_id` before processing a message. The same message can arrive more than once, for example if the app terminated after the handler succeeded but before Despia recorded the acknowledgement. An in-memory `Set` is sufficient for a single page session. Use IndexedDB if the check needs to survive reloads.
```javascript theme={null}
const seen = new Set()
window.onWebSocketEvent = function (evt) {
if (evt.type !== 'message') return
if (seen.has(evt.message_id)) return true
const data =
evt.dataType === 'json' ? evt.payload
: evt.dataType === 'binary' ? Uint8Array.from(atob(evt.payload), c => c.charCodeAt(0))
: evt.payload
return handleBusinessEvent(evt.id, data)
.then(() => { seen.add(evt.message_id); return true })
.catch(err => { console.error(err); return false })
}
```
***
## Sending messages
`websocket://send` writes a text frame. If the socket is down at call time, Despia saves the send to a durable outbound queue and flushes the queue in order once the connection is restored. Sends issued while offline are not lost.
```javascript theme={null}
if (isDespia) {
const payload = encodeURIComponent(JSON.stringify({ ping: 1 }))
despia(`websocket://send?id=orders&payload=${payload}`)
}
```
For binary frames, base64-encode the bytes before passing them as `payload`.
If a single send fails 25 consecutive times, Despia gives up on it and fires `send_failed` with its outbound id. The rest of the queue continues. Maintain your own mapping from `oid` to application intent if you need to surface the failure to the user.
```json theme={null}
{
"id": "orders",
"type": "send_failed",
"ts": 1747600099,
"oid": "orders#42"
}
```
***
## Resuming after reconnect
`websocket://subscribe` registers a frame that Despia sends to the server on every connect and reconnect. Include a cursor or last-seen id so the server can replay events that arrived while the socket was down.
Only one frame is stored per connection. The latest registration replaces the previous one. Omit `frame` to clear it. Update the registration as your cursor advances so the server always has an accurate resume point.
```javascript theme={null}
if (isDespia) {
const frame = encodeURIComponent(JSON.stringify({
action: 'subscribe',
topics: ['orders'],
since: lastCursor
}))
despia(`websocket://subscribe?id=orders&frame=${frame}`)
}
```
If more than 7 days pass without acknowledgement, or more than 5000 messages accumulate unacknowledged, Despia drops the oldest records from the durable store and fires `dropped`. Re-register the subscribe frame with a fresh cursor when this occurs.
***
## Checking connection status
`websocket://status` returns the current state of a connection as a `response` event. Include a `rid` to correlate the reply with the call.
```javascript theme={null}
if (isDespia) {
const rid = Date.now().toString()
despia(`websocket://status?id=orders&rid=${rid}`)
}
```
```json theme={null}
{
"id": "orders",
"type": "response",
"ts": 1747600200,
"rid": "1747600200000",
"ok": true,
"state": "open",
"reconnectAttempt": 0,
"pendingOutbound": 0,
"unacked": 2
}
```
`state` is one of `connecting`, `open`, `waitingReconnect`, or `closed`. `pendingOutbound` is the number of sends still queued. `unacked` is the number of incoming messages Despia has delivered but is holding in case of replay. Any command can include a `rid`. Despia echoes it back on the matching `response` event.
***
## Disconnecting
`websocket://disconnect` closes the socket and disables auto-reconnect so the connection does not resume when the app returns to the foreground. The durable store is preserved. Calling `connect` again with the same `id` picks up where it left off.
```javascript theme={null}
if (isDespia) {
despia('websocket://disconnect?id=orders')
}
```
A clean disconnect fires a `closed` event with `code: 1001` and `clean: true`. To point an id at a different URL, disconnect first, then issue a fresh `connect` with the new URL.
***
## Resources
despia-native
[support@despia.com](mailto:support@despia.com)
# Extensions
Source: https://setup.despia.com/custom-code/extensions
Reference for building native iOS and Android plugins for Despia.
Reference for the Despia Extension SDK. An Extension exposes native iOS and Android functionality to the web app through the standard `despia()` call, the same call exposed by the `despia-native` NPM package.
Extensions are currently reserved for Despia employees and Despia Whitelabel resellers. Public availability is not yet open. If you are a partner and need access, contact [support@despia.com](mailto:support@despia.com).
***
## How `despia()` works
The web app calls native code through a single global function published by `despia-native`:
```javascript theme={null}
import despia from 'despia-native'
// Fire and forget. No response expected.
despia('haptic://?style=light')
// Await a single response variable. Resolves within 30 seconds or with null on failure.
const data = await despia('battery://', ['battery_level'])
const level = data.battery_level
// Fire and listen. Define a window-scoped listener for an async push.
window.on_export_done = function(data) { saveTo(data.url) }
despia('export://?format=pdf')
```
Under the hood, the call assigns the URL string to `window.despia`. The native runtime intercepts the assignment, dispatches to the matching handler, and pushes results back to the WebView. There are two return channels: `window.` for awaited responses (read by `despia-native` via the variable observer with a 30-second timeout), and direct calls to `window.(payload)` for fire-and-listen and streaming patterns.
This is the contract every Extension implements. You expose a URL scheme, you write to a variable or call a window function, and the web app reads the response. You do not implement any JavaScript-side bridge.
***
## Three response patterns
There are exactly three ways an Extension can communicate back to the web app. The choice is determined by **whether the operation has a bounded duration**, not by how complex the response is.
| Pattern | Use when | Web app call | Native call |
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | -------------------------------------------------- |
| **Variable** | Bounded native work. You can guarantee completion in under 30 seconds. | `const data = await despia('scheme://', ['var'])` | `despia.variable("var", value)` |
| **Fire and listen** | Single response with no upper time bound. Anything waiting on the user, the network without timeout, or any unbounded operation. | `despia('scheme://')` then define `window. = function(data) {...}` | `despia.event("callback_name", payload)` |
| **Stream / event** | Multiple pushes over time. Observers, push notifications, in-progress scan results. | Define `window. = function(payload) {...}` once. | `despia.event("callback_name", payload)` per push. |
**Anything that waits for user input must use fire-and-listen, never variable.** Alert prompts, picker dialogs, native photo pickers, file pickers, biometric confirmation sheets, and OAuth login flows all wait on the user, who has no SLA. Treating these as awaited variables guarantees a 30-second hang and a `undefined` resolve when the user reads the dialog slowly.
### Variable
For deterministic native work where you can guarantee a result inside 30 seconds. Reading device sensors (battery, geolocation), reading SDK state (HealthKit step count, active RevenueCat entitlements, contacts), running a cryptographic operation, hitting your own backend with a timeout. The web app awaits one line and gets a value or `null`.
```javascript theme={null}
const data = await despia('battery://', ['battery_level'])
data.battery_level // 87 | null on failure
```
```swift theme={null}
despia.action("level") {
despia.variable("battery_level", 87)
}
```
If you cannot honestly say "this finishes in under 30 seconds, every time, on every device", do not use a variable. Use fire-and-listen.
### Fire and listen
For any operation with no enforceable upper time bound. The web app fires the action and registers a `window.` listener. The native side calls the listener when work finishes, however long that takes.
This is the right pattern for:
* **User input**: alert prompts, picker dialogs, photo / file pickers, biometric prompts, OAuth login windows, share sheets
* **Long-running native work**: PDF export, video transcoding, large file uploads, ML inference
* **Anything that calls into another app**: deep-linking out to settings or the camera, returning later
```javascript theme={null}
// Always define the listener BEFORE firing the action.
window.on_prompt_done = function(data) {
if (data.cancelled) return
console.log('User entered:', data.value)
}
despia('alert://prompt?message=What is your name?&placeholder=Jane')
```
```swift theme={null}
despia.action("prompt") {
let alert = UIAlertController(
title: nil,
message: despia.params.message,
preferredStyle: .alert
)
alert.addTextField { $0.placeholder = despia.params.placeholder }
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in
despia.event("on_prompt_done", ["cancelled": true, "value": ""])
})
alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in
let value = alert.textFields?.first?.text ?? ""
despia.event("on_prompt_done", ["cancelled": false, "value": value])
})
UIApplication.shared.topViewController?.present(alert, animated: true)
}
```
The naming convention is to prefix the callback with `on_` (e.g. `on_prompt_done`, `on_purchase_complete`, `on_export_finished`). The `despia.event` call is a no-op if the listener isn't defined, which is why listeners must be registered before firing.
### Stream / event
Same mechanism as fire-and-listen, but the native side calls the listener repeatedly. Use for live BLE scan results, push notification deliveries, customer-info change observers, and similar genuine streams.
```javascript theme={null}
// Called every time a new device appears.
window.on_device_discovered = function(device) {
appendDevice(device.name, device.rssi)
}
// The final result still goes through the variable channel.
const data = await despia('ble://?duration=5', ['ble_result'])
```
```swift theme={null}
// Inside the BLE delegate, called every discovery.
despia.event("on_device_discovered", ["name": name, "rssi": rssi, "uuid": uuid])
```
Variable and event channels combine freely. A single action can stream live updates via `event` while still resolving an awaited final-result variable, as long as the final result is bounded.
***
## Picking the right pattern
Use this decision flow for every action you build.
```text theme={null}
Does the action wait on the user (prompt, picker, biometric, OAuth, share sheet)?
YES → fire and listen
NO → next question
Can the action take longer than 30 seconds (large upload, video processing, ML)?
YES → fire and listen
NO → next question
Does the action push multiple values over time (observer, scan stream)?
YES → stream (event), or event + variable for the final result
NO → variable
```
When in doubt, prefer fire-and-listen. The cost is one extra `window.` definition. The cost of mis-using a variable for an unbounded operation is a 30-second hang for every user every time.
***
## Folder layout
```text theme={null}
despia-extension-/
├── despia-extension.json spec
├── extension_config.json local dev only, gitignored
├── .gitignore
└── Sources/
├── ios/.swift
└── android/.kt
```
Folder name must be `despia-extension-` matching the `scheme` field in the spec.
***
## Delivery
Two delivery paths. Both produce identical builds.
| Path | How |
| ---------------- | -------------------------------------------------------------------------------------------------------------------------- |
| Dashboard upload | Zip the folder, upload in the project's Extensions section. |
| GitHub sync | Place the folder under `/despia/extensions/` in your web app repo. Connect repo via **GitHub > Web App** in the dashboard. |
***
## API reference: `despia` global
The Extension SDK exposes a single global called `despia` in both Swift and Kotlin source files. It is implicit, no import needed. The full surface:
### `despia.action(name, closure)`
Registers a handler for `despia('://?...')`. Closures must register at file scope.
```swift theme={null}
despia.action("scan") {
// your code
}
```
```kotlin theme={null}
despia.action("scan") {
// your code
}
```
### `despia.hydration(closure)`
Runs once per WebView page load. Use for SDK initialisation. Make it idempotent.
```swift theme={null}
despia.hydration {
SDK.configure(apiKey: despia.env.api_key)
}
```
### `despia.params.`
Reads a typed URL param. The type is determined by the spec's declaration for that param.
```swift theme={null}
let id: String = despia.params.product_id // String, "" if missing
let qty: Int? = despia.params.quantity // optional for non-String types
let price: Double? = despia.params.price
let live: Bool? = despia.params.is_live
let meta: [String: Any]? = despia.params.metadata as? [String: Any]
```
```kotlin theme={null}
val id: String = despia.params["product_id"]
val qty: Int? = despia.params.int("quantity")
val price: Double? = despia.params.double("price")
val live: Boolean? = despia.params.bool("is_live")
val meta: Map<*, *>? = despia.params.json("metadata")
```
### `despia.env.`
Reads a value configured in the dashboard. Always returns `String`. Empty string if no value is configured. Defaults declared in the spec apply automatically.
```swift theme={null}
let key = despia.env.api_key
```
```kotlin theme={null}
val key = despia.env.api_key
```
### `despia.files[""].data` and `.array`
Reads binary data uploaded via `window.native.set_file()` from the web app. The web app sends a `@file/` token in the URL; the runtime resolves it to native bytes before your closure runs.
```swift theme={null}
let blob: Data? = despia.files["upload"].data
let blobs: [Data] = despia.files["uploads"].array
```
```kotlin theme={null}
val blob: ByteArray? = despia.files["upload"].data
val blobs: Array = despia.files["uploads"].array
```
UUIDs are single-use. Reading once consumes them.
### `despia.variable(name, value)`
Sets `window. = value`. This is the primary response channel. The web app reads it with `await despia('scheme://...', ['name'])`.
**Failure semantics: write `null` to signal failure.** `despia-native` resolves the awaited promise with `null` for that key, which is the standard "I tried and it didn't work" signal across all extensions.
```swift theme={null}
// Success
despia.variable("battery_level", 87)
// Failure
despia.variable("battery_level", NSNull())
```
```kotlin theme={null}
// Success
despia.variable("battery_level", 87)
// Failure
despia.variable("battery_level", null)
```
Values must be JSON-serialisable. Strings, numbers, booleans, arrays, and string-keyed maps. No `Date` (use ISO strings), no `URL` (use String).
### `despia.event(callbackName, payload)`
Calls `window.(payload)` if defined. Use this for the fire-and-listen pattern (single async response that may exceed 30 seconds) and for streams (multiple pushes over time). No-op if the web app hasn't defined the callback.
```swift theme={null}
// Fire and listen: one push when work completes
despia.event("on_export_done", ["url": fileURL])
// Stream: many pushes during a scan
despia.event("on_device_discovered", ["uuid": uuid, "rssi": rssi])
```
```kotlin theme={null}
despia.event("on_export_done", mapOf("url" to fileURL))
despia.event("on_device_discovered", mapOf("uuid" to uuid, "rssi" to rssi))
```
***
## Variable extension: battery level
The smallest possible extension. One action, one variable. Reads battery level. Resolves immediately.
### despia-extension.json
```json theme={null}
{
"$schema": "https://schema.despia.com/extension/v1",
"id": "com.example.battery",
"name": "Battery",
"scheme": "battery",
"version": "1.0.0",
"description": "Read device battery level.",
"author": "Example Inc.",
"minDespiaVersion": "2.0.0",
"platforms": ["ios", "android"],
"hosts": [
{
"name": "level",
"handler": "native",
"params": [],
"returns": { "varName": "battery_level" }
}
]
}
```
### Sources/ios/battery.swift
```swift theme={null}
import UIKit
despia.action("level") {
UIDevice.current.isBatteryMonitoringEnabled = true
let pct = UIDevice.current.batteryLevel
if pct < 0 {
despia.variable("battery_level", NSNull()) // unknown
} else {
despia.variable("battery_level", Int(pct * 100))
}
}
```
### Sources/android/battery.kt
```kotlin theme={null}
import android.content.Intent
import android.content.IntentFilter
import android.os.BatteryManager
despia.action("level") {
val intent = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
val level = intent?.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) ?: -1
val scale = intent?.getIntExtra(BatteryManager.EXTRA_SCALE, -1) ?: -1
if (level < 0 || scale <= 0) {
despia.variable("battery_level", null)
} else {
despia.variable("battery_level", (level * 100) / scale)
}
}
```
### Web app
```javascript theme={null}
import despia from 'despia-native'
const data = await despia('battery://', ['battery_level'])
if (data.battery_level === null) {
console.log('Battery level unavailable')
} else {
console.log(`Battery: ${data.battery_level}%`)
}
```
That is the entire pattern. Three files, twenty lines each, one line of caller code.
***
## Fire-and-listen extension: alert prompt
The canonical user-input case. The web app shows a native text-input prompt and waits for the user to type. Because the user has no SLA, this **must** be fire-and-listen, not variable. A user reading the dialog for 31 seconds would otherwise hang the call.
### despia-extension.json
```json theme={null}
{
"$schema": "https://schema.despia.com/extension/v1",
"id": "com.example.alert",
"name": "Alert Prompt",
"scheme": "alert",
"version": "1.0.0",
"description": "Show a native text-input prompt.",
"author": "Example Inc.",
"minDespiaVersion": "2.0.0",
"platforms": ["ios", "android"],
"hosts": [
{
"name": "prompt",
"handler": "native",
"params": [
{ "name": "message", "type": "String", "required": true },
{ "name": "placeholder", "type": "String", "required": false },
{ "name": "default", "type": "String", "required": false }
],
"returns": null
}
],
"events": [
{
"name": "on_prompt_done",
"callbackName": "on_prompt_done",
"payload": [
{ "name": "cancelled", "type": "Bool" },
{ "name": "value", "type": "String" }
]
}
]
}
```
`returns` is `null` because the action does not write a variable. The completion is delivered through the `on_prompt_done` event when (or if) the user dismisses the dialog.
### Sources/ios/alert.swift
```swift theme={null}
import UIKit
private extension UIApplication {
var topViewController: UIViewController? {
var vc = connectedScenes
.compactMap { ($0 as? UIWindowScene)?.keyWindow?.rootViewController }
.first
while let presented = vc?.presentedViewController { vc = presented }
return vc
}
}
despia.action("prompt") {
let message = despia.params.message
let placeholder = despia.params.placeholder
let initial = despia.params.default
let alert = UIAlertController(title: nil, message: message, preferredStyle: .alert)
alert.addTextField { tf in
tf.placeholder = placeholder
tf.text = initial
}
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in
despia.event("on_prompt_done", ["cancelled": true, "value": ""])
})
alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in
let value = alert.textFields?.first?.text ?? ""
despia.event("on_prompt_done", ["cancelled": false, "value": value])
})
DispatchQueue.main.async {
UIApplication.shared.topViewController?.present(alert, animated: true)
}
}
```
### Sources/android/alert.kt
```kotlin theme={null}
import android.app.AlertDialog
import android.text.InputType
import android.widget.EditText
import android.os.Handler
import android.os.Looper
despia.action("prompt") {
val message = despia.params["message"]
val placeholder = despia.params["placeholder"]
val initial = despia.params["default"]
Handler(Looper.getMainLooper()).post {
val input = EditText(activity).apply {
inputType = InputType.TYPE_CLASS_TEXT
hint = placeholder
setText(initial)
}
AlertDialog.Builder(activity)
.setMessage(message)
.setView(input)
.setPositiveButton("OK") { _, _ ->
despia.event("on_prompt_done", mapOf(
"cancelled" to false,
"value" to input.text.toString()
))
}
.setNegativeButton("Cancel") { _, _ ->
despia.event("on_prompt_done", mapOf(
"cancelled" to true,
"value" to ""
))
}
.setOnCancelListener {
despia.event("on_prompt_done", mapOf(
"cancelled" to true,
"value" to ""
))
}
.show()
}
}
```
Notice both implementations always emit `on_prompt_done` exactly once, on every exit path: OK, Cancel, and (Android) tap-outside-to-dismiss. Failing to emit on any path leaves the listener hanging forever.
### Web app
```javascript theme={null}
import despia from 'despia-native'
// Define the listener BEFORE firing. The native side may complete before
// the JS event loop runs again, and despia.event is a no-op without a listener.
window.on_prompt_done = function(result) {
if (result.cancelled) {
console.log('User cancelled')
return
}
console.log('User entered:', result.value)
}
function askName() {
despia('alert://prompt'
+ '?message=' + encodeURIComponent('What is your name?')
+ '&placeholder=' + encodeURIComponent('Jane'))
}
```
The reason this can never be a variable is simple: the user might take 5 seconds, 5 minutes, or 5 hours. There is no upper bound. The 30-second variable timeout is for deterministic native operations only.
***
## Stream extension: BLE scanner
Combines the variable channel (final scan result, bounded by the `duration` param) with the event channel (live discoveries during the scan). Also demonstrates typed params, dashboard config, and capabilities.
### despia-extension.json
```json theme={null}
{
"$schema": "https://schema.despia.com/extension/v1",
"id": "com.example.ble",
"name": "Bluetooth LE",
"scheme": "ble",
"version": "1.0.0",
"description": "Scan for nearby Bluetooth Low Energy devices.",
"author": "Example Inc.",
"minDespiaVersion": "2.0.0",
"platforms": ["ios", "android"],
"vars": [
{
"name": "service_uuid_filter",
"type": "String",
"required": false,
"label": "Service UUID filter",
"hint": "Leave blank to scan all devices."
}
],
"hosts": [
{
"name": "scan",
"handler": "native",
"params": [
{ "name": "duration", "type": "Int", "required": false }
],
"returns": { "varName": "ble_result" }
}
],
"events": [
{
"name": "on_device_discovered",
"callbackName": "on_device_discovered",
"payload": [
{ "name": "name", "type": "String" },
{ "name": "uuid", "type": "String" },
{ "name": "rssi", "type": "Int" }
]
}
],
"capabilities": {
"ios": [
{
"key": "NSBluetoothAlwaysUsageDescription",
"setup": "auto",
"value": "Used to discover nearby devices."
}
],
"android": [
{ "key": "android.permission.BLUETOOTH_SCAN", "setup": "auto" },
{ "key": "android.permission.BLUETOOTH_CONNECT", "setup": "auto" },
{ "key": "android.permission.ACCESS_FINE_LOCATION", "setup": "auto" }
]
}
}
```
### Sources/ios/ble.swift
```swift theme={null}
import CoreBluetooth
private final class BLEManager: NSObject, CBCentralManagerDelegate {
static let shared = BLEManager()
private var central: CBCentralManager?
private var found: [String: [String: Any]] = [:]
private var done: (([[String: Any]]) -> Void)?
func scan(duration: Int, filter: String?) async -> [[String: Any]]? {
await withCheckedContinuation { cont in
self.found = [:]
self.done = { cont.resume(returning: $0) }
if self.central == nil {
self.central = CBCentralManager(delegate: self, queue: nil)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
let services = filter.flatMap { [CBUUID(string: $0)] }
self.central?.scanForPeripherals(withServices: services, options: nil)
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(duration)) {
self.central?.stopScan()
self.done?(Array(self.found.values))
self.done = nil
}
}
}
}
func centralManagerDidUpdateState(_ central: CBCentralManager) {}
func centralManager(_ central: CBCentralManager,
didDiscover peripheral: CBPeripheral,
advertisementData: [String: Any],
rssi RSSI: NSNumber) {
let uuid = peripheral.identifier.uuidString
let dev: [String: Any] = [
"name": peripheral.name ?? "Unknown",
"uuid": uuid,
"rssi": RSSI.intValue
]
if found[uuid] == nil { despia.event("on_device_discovered", dev) }
found[uuid] = dev
}
}
despia.action("scan") {
let duration = despia.params.duration as? Int ?? 5
let filter = despia.env.service_uuid_filter
let devices = await BLEManager.shared.scan(
duration: duration,
filter: filter.isEmpty ? nil : filter
)
if let devices {
despia.variable("ble_result", ["devices": devices, "count": devices.count])
} else {
despia.variable("ble_result", NSNull())
}
}
```
### Sources/android/ble.kt
```kotlin theme={null}
import android.bluetooth.BluetoothAdapter
import android.bluetooth.le.*
import android.os.Handler
import android.os.Looper
import android.os.ParcelUuid
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
private object BLEManager {
private val adapter = BluetoothAdapter.getDefaultAdapter()
private val found = mutableMapOf>()
private var cb: ScanCallback? = null
private val handler = Handler(Looper.getMainLooper())
suspend fun scan(durationSec: Int, filter: String?): List