> ## Documentation Index
> Fetch the complete documentation index at: https://setup.despia.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Purchases

> Purchase a specific product directly using RevenueCat through Apple App Store and Google Play billing.

<Card icon="sparkles" title="AI Prompt">
  Add native in-app purchase functionality to my app using the Despia SDK from: [https://www.npmjs.com/package/despia-native](https://www.npmjs.com/package/despia-native)

  First, install the package: `npm install despia-native`

  Then import it: `import despia from 'despia-native'`

  Create a payment system that uses RevenueCat URLs like: `despia("revenuecat://purchase?external_id={USER_ID}&product={PRODUCT_ID}")` to handle purchases through Apple App Store and Google Play billing.

  Detect the platform using the Despia user agent and use the correct product identifier for each store. If not running in Despia (i.e. the user is on your web app), fall back to a RevenueCat Web Purchase Link:

  ```javascript theme={null}
  const isDespia = navigator.userAgent.toLowerCase().includes('despia');
  const isDespiaIOS = isDespia && (
    navigator.userAgent.toLowerCase().includes('iphone') ||
    navigator.userAgent.toLowerCase().includes('ipad')
  );
  const isDespiaAndroid = isDespia &&
    navigator.userAgent.toLowerCase().includes('android');
  ```

  Use platform-specific product IDs. On Android, subscription product IDs must be prefixed with the subscription group name as configured in Google Play billing:

  * iOS: `monthly_premium_ios`
  * Android: `premium:monthly_premium_android` (group name `premium` + `:` + product ID)

  For the web fallback, redirect to a RevenueCat Web Purchase Link with the user's ID appended:

  ```javascript theme={null}
  if (isDespiaIOS) {
    despia(`revenuecat://purchase?external_id=${userId}&product=monthly_premium_ios`);
  } else if (isDespiaAndroid) {
    despia(`revenuecat://purchase?external_id=${userId}&product=premium:monthly_premium_android`);
  } else {
    // Web - redirect to RevenueCat Web Purchase Link
    window.location.href = `https://pay.rev.cat/<your_token>/${encodeURIComponent(userId)}`;
  }
  ```

  <Danger>
    Always append the user's ID to the RevenueCat Web Purchase Link URL. This links the web payment directly to their account - so if they later open your mobile app, the purchase and entitlements are already there waiting for them.
  </Danger>

  In RevenueCat, create a single entitlement (e.g. `"premium"`) and link both the iOS and Android products to it. This means `entitlementId` will be `"premium"` for all platforms - check with `p.entitlementId === "premium"`. For one-time purchases, match the exact `entitlementId` (e.g. `"no_ads"`).

  <Note>
    The Despia Native Runtime will call the global function `onRevenueCatPurchase()` when an in-app purchase or subscription is successfully made on the client side. This should not grant access immediately on its own - but it is the right moment to either poll your backend until the RevenueCat webhook confirms the purchase, or run a client-side entitlement check via `getpurchasehistory://` to instantly verify active entitlements with no backend needed. You should also call `getpurchasehistory://` proactively on app load, page navigation, and before gating any premium feature. These checks are instant, offline-capable, and require no network round-trip to your server.
  </Note>

  <Danger>
    **This feature requires native capabilities which will be fully provided by the "despia-native" npm package, no additional native libraries are needed!**
  </Danger>

  Payment confirmation will happen via RevenueCat's Webhooks, so make sure to include or instruct me to set up the correct backend structure.

  **You can find the documentation about this here:** [**https://www.revenuecat.com/docs/integrations/webhooks**](https://www.revenuecat.com/docs/integrations/webhooks)

  * Read and follow all best practices for Webhook events here: [**https://www.revenuecat.com/docs/integrations/webhooks/event-types-and-fields**](https://www.revenuecat.com/docs/integrations/webhooks/event-types-and-fields)
  * Check out the sample Webhook event schemas here: [**https://www.revenuecat.com/docs/integrations/webhooks/sample-events**](https://www.revenuecat.com/docs/integrations/webhooks/sample-events)

  Once the payment is confirmed on the backend, the backend should send a webhook to the frontend to let it know that this user's in-app purchase session was completed.

  Also add a **Restore Purchases** button that calls: `despia("getpurchasehistory://", ["restoredData"])`

  This will query the native app stores and return all purchases associated with the current device/account, including active subscriptions, expired subscriptions, and one-time purchases.

  The response array includes objects with:

  * `transactionId` - unique identifier for this specific transaction
  * `originalTransactionId` - identifier linking to the original purchase (useful for subscription renewals)
  * `productId` - the product identifier configured in App Store Connect / Google Play Console
  * `type` - either `"subscription"` or `"product"` (one-time purchase)
  * `entitlementId` - the entitlement/access level this purchase grants
  * `isActive` - boolean indicating if the purchase currently grants access
  * `willRenew` - boolean indicating if a subscription will auto-renew
  * `purchaseDate` - ISO timestamp of the most recent transaction
  * `originalPurchaseDate` - ISO timestamp of the initial purchase
  * `expirationDate` - ISO timestamp when access expires (null for lifetime purchases)
  * `store` - either `"app_store"` or `"play_store"`
  * `country` - user's country code
  * `environment` - `"production"` or `"sandbox"`
  * `receipt` - the raw receipt data for server-side validation

  Use the restore response to check active entitlements and re-grant access where appropriate. Here is an example of how to check if the user has an active `"premium"` entitlement:

  ```javascript theme={null}
  const data = await despia("getpurchasehistory://", ["restoredData"]);
  const purchases = data.restoredData;

  // Filter for active purchases only
  const activePurchases = purchases.filter(p => p.isActive);

  // Check if user has premium access
  const hasPremium = activePurchases.some(p => p.entitlementId === "premium");

  if (hasPremium) {
    // Grant premium features
  }
  ```

  Please follow the installation instructions for the "despia-native" npm package closely, and do not modify my instructions. Implementation as mentioned is critical.
</Card>

**How it Works:** Despia acts as a bridge between your app and native mobile payment systems. When a user taps a purchase button, Despia triggers the native RevenueCat purchase flow for the specified product - directly through Apple's App Store or Google Play Store - without opening a full paywall UI. The purchase flow handles secure transactions and automatic subscription management.

## Installation

Install the Despia package from NPM:

```bash theme={null}
npm install despia-native
```

## Usage

### 1. Import the SDK

```javascript theme={null}
import despia from 'despia-native';
```

### 2. Detect Platform & Purchase

Always check for Despia first before attempting native purchases. If not running in Despia (e.g. the user is on your web app), fall back to a RevenueCat Web Purchase Link instead.

```javascript theme={null}
// Detect if running in Despia
const isDespia = navigator.userAgent.toLowerCase().includes('despia');

// Detect iOS in Despia
const isDespiaIOS = isDespia && (
  navigator.userAgent.toLowerCase().includes('iphone') ||
  navigator.userAgent.toLowerCase().includes('ipad')
);

// Detect Android in Despia
const isDespiaAndroid = isDespia &&
  navigator.userAgent.toLowerCase().includes('android');

function handleUpgrade() {
  if (isDespiaIOS) {
    // Native iOS purchase - plain product ID
    despia(`revenuecat://purchase?external_id=${userId}&product=monthly_premium_ios`);

  } else if (isDespiaAndroid) {
    // Native Android purchase - prefixed with Play subscription group: group:productId
    despia(`revenuecat://purchase?external_id=${userId}&product=premium:monthly_premium_android`);

  } else {
    // Web fallback - redirect to RevenueCat Web Purchase Link
    // Append the user's ID so the purchase is linked to their account
    // and entitlements are available in the mobile app too
    window.location.href = `https://pay.rev.cat/<your_token>/${encodeURIComponent(userId)}`;
  }
}
```

<Danger>
  **Always include the user's ID in the Web Purchase Link URL. This ties the web payment to their account so entitlements are instantly available across both your web app and mobile app - no extra steps needed for the user.**
</Danger>

#### Web Checkout Link (non-Despia fallback)

To get your `pay.rev.cat` token, go to RevenueCat and create a new Web Purchase Link - select an existing offering or create a new one with your web products:

[**RevenueCat -> Web -> Create Purchase Link**](https://app.revenuecat.com)

Once created, append the user's `app_user_id` directly to the URL - this is the critical part that links the web purchase to their account:

```bash theme={null}
https://pay.rev.cat/<token>/<userId>
```

Optional URL parameters (all should be [URL encoded](https://www.urlencoder.org/)):

| Parameter                    | Description                                                            |
| ---------------------------- | ---------------------------------------------------------------------- |
| `email`                      | Pre-fills the email field on checkout - `?email=<customerEmail>`       |
| `currency`                   | Override automatic currency selection - `?currency=EUR`                |
| `package_id`                 | Pre-select a package and skip straight to checkout                     |
| `skip_purchase_success=true` | Skip the success page and trigger your configured redirect immediately |

<Danger>
  The `app_user_id` in the URL is what RevenueCat includes in every webhook event. **The user must already exist in your database before redirecting them to the checkout link** - otherwise when the `INITIAL_PURCHASE` webhook fires, your backend won't find the user and won't be able to grant access.
</Danger>

When the purchase completes, RevenueCat fires a webhook to your backend. Since it's linked to the same `app_user_id`, the user's entitlements will be active in the mobile app too.

### 3. Handle the Purchase Callback

The Despia Native Runtime calls `onRevenueCatPurchase()` globally as soon as the store confirms a transaction. This is your signal that something happened - but not yet proof that access should be granted.

There are two patterns for what to do inside this callback:

#### With a backend - poll until the webhook confirms

```javascript theme={null}
window.onRevenueCatPurchase = async () => {
  // The RevenueCat webhook will hit your server shortly.
  // Poll until your backend confirms the user's status has updated.
  const verified = await pollForPurchaseVerification(userId);
  if (verified) {
    setIsPremium(true);
  }
};
```

#### Without a backend - run the client-side entitlement check

If you don't have a backend, skip the polling and check the native store directly using `getpurchasehistory://`. This queries Apple or Google in real time and returns the user's active entitlements instantly - no server, no network dependency beyond the store itself.

```javascript theme={null}
window.onRevenueCatPurchase = async () => {
  const data = await despia("getpurchasehistory://", ["restoredData"]);
  const active = (data.restoredData ?? []).filter(p => p.isActive);

  if (active.some(p => p.entitlementId === "premium")) {
    unlockPremiumFeatures();
  }
  if (active.some(p => p.entitlementId === "no_ads")) {
    removeAds();
  }
};
```

<Danger>
  **Never grant access based on the `onRevenueCatPurchase` callback alone if you have a backend.** Always wait for your backend to confirm via the RevenueCat webhook before unlocking features. The callback is a trigger to start checking - not confirmation of a valid purchase.
</Danger>

### 4. Run entitlement checks proactively

Don't wait for a purchase event to check entitlements. Call `getpurchasehistory://` proactively in three places:

| When                         | Why                                                                                |
| ---------------------------- | ---------------------------------------------------------------------------------- |
| **App load / page mount**    | Restore state for returning users immediately, before any interaction              |
| **Page navigation**          | Re-gate features as users move through the app without requiring a full reload     |
| **Before any gated feature** | Catch edge cases where entitlement state changed mid-session (e.g. expiry, refund) |

These checks are instant and offline-capable. Apple and Google cache entitlement state on-device, so they don't require a live network connection to your server.

```javascript theme={null}
// Run on app load
async function checkEntitlementsOnLoad() {
  const data = await despia("getpurchasehistory://", ["restoredData"]);
  const active = (data.restoredData ?? []).filter(p => p.isActive);

  if (active.some(p => p.entitlementId === "premium")) {
    unlockPremiumFeatures();
  }
  if (active.some(p => p.entitlementId === "no_ads")) {
    removeAds();
  }
}

checkEntitlementsOnLoad();

// Re-check before gating any feature
async function guardPremiumFeature() {
  const data = await despia("getpurchasehistory://", ["restoredData"]);
  const active = (data.restoredData ?? []).filter(p => p.isActive);
  const hasPremium = active.some(p => p.entitlementId === "premium");

  if (!hasPremium) {
    // No active entitlement - trigger the purchase flow instead
    if (isDespiaIOS) {
      despia(`revenuecat://purchase?external_id=${userId}&product=monthly_premium_ios`);
    } else if (isDespiaAndroid) {
      despia(`revenuecat://purchase?external_id=${userId}&product=premium:monthly_premium_android`);
    } else {
      window.location.href = `https://pay.rev.cat/<your_token>/${encodeURIComponent(userId)}`;
    }
    return false;
  }

  return true;
}

// Usage before any premium action:
if (await guardPremiumFeature()) {
  openPremiumContent();
}

// Purchase callback - reuse the same check
window.onRevenueCatPurchase = checkEntitlementsOnLoad;
```

<Tip>
  Because `onRevenueCatPurchase` and your on-load check do the same thing, you can point them at the same function. One consistent entitlement check, called in all the right places.
</Tip>

## Complete Client-Side Example

### Set up entitlements in RevenueCat

Before checking entitlements in code, set them up in your RevenueCat dashboard:

1. Go to [**RevenueCat Dashboard**](https://app.revenuecat.com) -> your project -> **Entitlements**
2. Click **New Entitlement** and name it `premium` (or `no_ads` for one-time purchases)
3. Click into the entitlement and click **Attach products**
4. Attach your iOS product (e.g. `monthly_premium_ios`) and your Android product (e.g. `premium:monthly_premium_android`)
5. Both platforms will now resolve to the same `entitlementId` in your app

Repeat for any additional entitlements (e.g. `no_ads`).

### Check entitlements client-side

`getpurchasehistory://` queries the native store directly and returns real-time entitlement state from Apple or Google. No backend required for native purchases.

```javascript theme={null}
const data = await despia("getpurchasehistory://", ["restoredData"]);
const purchases = data.restoredData;

// Filter for active purchases only
const activePurchases = purchases.filter(p => p.isActive);

// Check if user has premium access
const hasPremium = activePurchases.some(p => p.entitlementId === "premium");

if (hasPremium) {
  unlockPremiumFeatures();
}

// One-time purchase check
const hasNoAds = activePurchases.some(p => p.entitlementId === "no_ads");

if (hasNoAds) {
  removeAds();
}
```

<Warning>
  This client-side check only works for **native iOS and Android purchases**. If the user purchased on web, `getpurchasehistory://` will return empty even if they have an active subscription. For apps with web payments you need a single source of truth - your backend. Check your backend first and only fall back to the native store check if there is no backend record.
</Warning>

### Full example

```javascript theme={null}
import despia from 'despia-native';

// Platform detection
const isDespia = navigator.userAgent.toLowerCase().includes('despia');
const isDespiaIOS = isDespia && (
  navigator.userAgent.toLowerCase().includes('iphone') ||
  navigator.userAgent.toLowerCase().includes('ipad')
);
const isDespiaAndroid = isDespia &&
  navigator.userAgent.toLowerCase().includes('android');

// Product ID format:
//   iOS:     "monthly_premium_ios"             (plain)
//   Android: "premium:monthly_premium_android"  (Google Play group:productId format)
const PRODUCTS = {
  premiumMonthly: isDespiaIOS ? "monthly_premium_ios" : "premium:monthly_premium_android",
  premiumAnnual:  isDespiaIOS ? "annual_premium_ios"  : "premium:annual_premium_android",
  removeAds: "remove_ads",
};

// Entitlement check - instant and offline-capable
async function checkEntitlementsOnLoad() {
  const data = await despia("getpurchasehistory://", ["restoredData"]);
  const active = (data.restoredData ?? []).filter(p => p.isActive);

  if (active.some(p => p.entitlementId === "premium")) {
    unlockPremiumFeatures();
  }
  if (active.some(p => p.entitlementId === "no_ads")) {
    removeAds();
  }
}

// Guard for gating premium features
async function guardPremiumFeature() {
  const data = await despia("getpurchasehistory://", ["restoredData"]);
  const active = (data.restoredData ?? []).filter(p => p.isActive);
  const hasPremium = active.some(p => p.entitlementId === "premium");

  if (!hasPremium) {
    // No active entitlement - trigger the purchase flow instead
    if (isDespiaIOS) {
      despia(`revenuecat://purchase?external_id=${userId}&product=${PRODUCTS.premiumMonthly}`);
    } else if (isDespiaAndroid) {
      despia(`revenuecat://purchase?external_id=${userId}&product=${PRODUCTS.premiumMonthly}`);
    } else {
      window.location.href = `https://pay.rev.cat/<your_token>/${encodeURIComponent(userId)}`;
    }
    return false;
  }

  return true;
}

// Run on app load
checkEntitlementsOnLoad();

// Purchase callback - reuse the same check
window.onRevenueCatPurchase = checkEntitlementsOnLoad;

// Purchase buttons
function handleUpgradePremium() {
  if (isDespiaIOS) {
    despia(`revenuecat://purchase?external_id=${userId}&product=${PRODUCTS.premiumMonthly}`);
  } else if (isDespiaAndroid) {
    despia(`revenuecat://purchase?external_id=${userId}&product=${PRODUCTS.premiumMonthly}`);
  } else {
    window.location.href = `https://pay.rev.cat/<your_token>/${encodeURIComponent(userId)}`;
  }
}

function handleRemoveAds() {
  if (isDespia) {
    despia(`revenuecat://purchase?external_id=${userId}&product=${PRODUCTS.removeAds}`);
  } else {
    window.location.href = `https://pay.rev.cat/<your_token>/${encodeURIComponent(userId)}`;
  }
}

// Restore button (required by App Store guidelines)
async function handleRestore() {
  const data = await despia("getpurchasehistory://", ["restoredData"]);
  const active = (data.restoredData ?? []).filter(p => p.isActive);

  if (active.length === 0) {
    alert("No active purchases found.");
    return;
  }

  if (active.some(p => p.entitlementId === "premium")) {
    unlockPremiumFeatures();
  }
  if (active.some(p => p.entitlementId === "no_ads")) {
    removeAds();
  }
}

// Usage before any premium action:
if (await guardPremiumFeature()) {
  openPremiumContent();
}
```

## Subscriptions: Set Up Webhooks for Best Practice

Client-side entitlement checks work well for simple apps, but **server-side webhook events are always recommended for subscriptions**. Webhooks let you:

* **Revoke access the moment a subscription expires** - rather than waiting for the next client-side check
* **Log users out of your web app** when their subscription lapses
* **Sync subscription state** across all platforms and databases in real time
* **React to cancellations, refunds, and billing issues** automatically

<Card icon="webhook" href="https://setup.despia.com/best-practices/backend/revenue-cat/webhooks" title="Despia Webhook Starter Template">
  A full backend webhook handler template covering all RevenueCat event types, database schema, and best practices.
</Card>

### 1. Add a webhook endpoint to your server

Create a `POST /webhooks/revenuecat` endpoint. RevenueCat sends an `Authorization` header with a secret you define - just make up a random string and store it as an environment variable.

<Warning>
  Some backends like Supabase Edge Functions or Convex apply their own authentication middleware by default, which will block incoming RevenueCat requests before your code runs. Make sure to **disable the default auth middleware on this endpoint** so RevenueCat's `Authorization: Bearer` token can reach your handler and be validated there.
</Warning>

```javascript theme={null}
const WEBHOOK_SECRET = process.env.REVENUECAT_WEBHOOK_SECRET; // e.g. "xK9mP2qRt7vL"

export async function POST(req) {
  // 1. Validate the Authorization header
  const auth = req.headers.get('authorization') ?? '';
  const token = auth.startsWith('Bearer ') ? auth.slice(7) : auth;

  if (token !== WEBHOOK_SECRET) {
    return new Response('Unauthorized', { status: 401 });
  }

  // 2. Parse the event
  const body = await req.json();
  const event = body.event;
  if (!event) return new Response('Bad Request', { status: 400 });

  const { type, app_user_id, environment } = event;

  // 3. Handle sandbox events - skip unless user is a tester
  // Mark users as testers in your DB (e.g. is_tester: true) so they can
  // verify the full purchase flow in sandbox without affecting real users
  if (environment === 'SANDBOX') {
    const user = await db.findOne('users', { app_user_id });
    if (!user?.is_tester) {
      return new Response('OK', { status: 200 });
    }
    // User is a tester - fall through and process as if production
  }

  // 4. Extract entitlements - RC webhook sends entitlement_ids as a flat array
  // e.g. ["premium"] and entitlement_id as the primary single string
  // store values are uppercase: "APP_STORE" | "PLAY_STORE" | "RC_BILLING" | "STRIPE"
  const entitlementIds = event.entitlement_ids ?? [];
  const hasPremium = entitlementIds.includes('premium');

  // 5. Handle the event type
  switch (type) {
    case 'INITIAL_PURCHASE':
    case 'RENEWAL':
    case 'UNCANCELLATION':
    case 'SUBSCRIPTION_EXTENDED':
      // Grant access - update your database
      await db.upsert('user_subscriptions', {
        app_user_id,
        is_active: true,
        subscription_status: 'active',
        entitlements: JSON.stringify(entitlementIds),
        expires_at: event.expiration_at_ms ? new Date(event.expiration_at_ms) : null,
        product_id: event.product_id,
        store: event.store,
        last_webhook_event: type,
        last_updated: new Date(),
      });
      break;

    case 'CANCELLATION':
      // Subscription cancelled but access continues until expiry
      await db.upsert('user_subscriptions', {
        app_user_id,
        is_active: true, // still active until expires_at
        subscription_status: 'cancelled',
        expires_at: event.expiration_at_ms ? new Date(event.expiration_at_ms) : null,
        last_webhook_event: type,
        last_updated: new Date(),
      });
      break;

    case 'EXPIRATION':
    case 'REFUND':
      // Revoke access immediately
      await db.upsert('user_subscriptions', {
        app_user_id,
        is_active: false,
        subscription_status: 'expired',
        last_webhook_event: type,
        last_updated: new Date(),
      });
      break;

    case 'BILLING_ISSUE':
      // Grace period - flag but keep active for now
      await db.upsert('user_subscriptions', {
        app_user_id,
        billing_issue: true,
        grace_period_expires_at: event.grace_period_expires_at_ms
          ? new Date(event.grace_period_expires_at_ms)
          : null,
        last_webhook_event: type,
        last_updated: new Date(),
      });
      break;
  }

  return new Response('OK', { status: 200 });
}
```

### 2. Database schema

Create a `user_subscriptions` table to store subscription state:

```sql theme={null}
CREATE TABLE user_subscriptions (
  app_user_id             VARCHAR(255) PRIMARY KEY,
  is_active               BOOLEAN DEFAULT false,
  subscription_status     VARCHAR(50),   -- active, cancelled, expired, billing_issue
  product_id              VARCHAR(255),
  entitlements            JSONB,
  expires_at              TIMESTAMP,
  grace_period_expires_at TIMESTAMP,
  billing_issue           BOOLEAN DEFAULT false,
  store                   VARCHAR(50),   -- app_store, play_store
  last_webhook_event      VARCHAR(100),
  last_updated            TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_user_subs_active  ON user_subscriptions(is_active);
CREATE INDEX idx_user_subs_expires ON user_subscriptions(expires_at);
```

### 3. Register the webhook in RevenueCat

1. Go to **RevenueCat Dashboard -> Project -> Integrations -> Webhooks**
2. Click **Add new webhook**
3. Set the **URL** to your endpoint - e.g. `https://yourapp.com/webhooks/revenuecat`
4. Set the **Authorization header** to the same random secret you put in your env var
5. Select **All events**
6. Save - RevenueCat will now send events to your server in real time

### 4. Consider a cron job as backup

Webhooks can occasionally fail (server downtime, timeouts, etc.). A cron job that periodically syncs subscription status from the RevenueCat API is a reliable safety net - especially important for catching cancellations and expirations that your webhook might have missed.

<Card icon="clock" href="https://setup.despia.com/best-practices/backend/revenue-cat/webhooks" title="Cron Job Template">
  Our full backend template includes a self-healing cron job that runs every 5 minutes, prioritizes expiring subscriptions, and stays within RevenueCat's API rate limits. Combined with webhooks it gives 99.9%+ sync reliability.
</Card>

## Restore Purchases

Despia queries the native platform's billing system to retrieve all purchases associated with the current user's App Store or Google Play account. This includes active subscriptions, expired subscriptions, consumables, and non-consumable (lifetime) purchases. The data is normalized into a consistent format across both iOS and Android platforms.

### 1. Retrieve Purchase History

```javascript theme={null}
const data = await despia("getpurchasehistory://", ["restoredData"]);
const purchases = data.restoredData;
console.log(purchases);
```

### 2. Example Response (iOS)

```json theme={null}
[
  {
    "transactionId": "1000000987654321",
    "originalTransactionId": "1000000123456789",
    "productId": "com.app.premium.monthly",
    "type": "subscription",
    "entitlementId": "premium",
    "externalUserId": "abc123",
    "isAnonymous": false,
    "isActive": true,
    "willRenew": true,
    "purchaseDate": "2024-01-15T14:32:05Z",
    "originalPurchaseDate": "2023-06-20T09:15:33Z",
    "expirationDate": "2024-02-15T14:32:05Z",
    "store": "app_store",
    "country": "USA",
    "receipt": "MIIbngYJKoZIhvcNAQcCoIIbajCCG2YCAQExDzAN...",
    "environment": "production"
  },
  {
    "transactionId": "1000000555555555",
    "originalTransactionId": "1000000555555555",
    "productId": "com.app.removeads",
    "type": "product",
    "entitlementId": "no_ads",
    "externalUserId": "abc123",
    "isAnonymous": false,
    "isActive": true,
    "willRenew": false,
    "purchaseDate": "2023-12-01T08:00:00Z",
    "originalPurchaseDate": "2023-12-01T08:00:00Z",
    "expirationDate": null,
    "store": "app_store",
    "country": "USA",
    "receipt": "MIIbngYJKoZIhvcNAQcCoIIbajCCG2YCAQExDzAN...",
    "environment": "production"
  }
]
```

### 3. Example Response (Android)

```json theme={null}
[
  {
    "transactionId": "GPA.3372-4150-9088-12345",
    "originalTransactionId": "GPA.3372-4150-9088-12345",
    "productId": "com.app.premium.monthly",
    "type": "subscription",
    "entitlementId": "premium",
    "externalUserId": "abc123",
    "isAnonymous": false,
    "isActive": true,
    "willRenew": true,
    "purchaseDate": "2024-01-15T14:32:05Z",
    "originalPurchaseDate": "2023-06-20T09:15:33Z",
    "expirationDate": "2024-02-15T14:32:05Z",
    "store": "play_store",
    "country": "US",
    "receipt": "kefhajglhaljhfajkfajk.AO-J1OxBnT3hAjkl5FjpKc9...",
    "environment": "production"
  },
  {
    "transactionId": "GPA.3372-4150-9088-67890",
    "originalTransactionId": "GPA.3372-4150-9088-67890",
    "productId": "com.app.removeads",
    "type": "product",
    "entitlementId": "no_ads",
    "externalUserId": "abc123",
    "isAnonymous": false,
    "isActive": true,
    "willRenew": false,
    "purchaseDate": "2023-12-01T08:00:00Z",
    "originalPurchaseDate": "2023-12-01T08:00:00Z",
    "expirationDate": null,
    "store": "play_store",
    "country": "US",
    "receipt": "minodkpfokbofclncmaa.AO-J1Oy2fXpTml7rKxE3vNc9...",
    "environment": "production"
  }
]
```

### 4. Check Active Entitlements

```javascript theme={null}
const data = await despia("getpurchasehistory://", ["restoredData"]);
const purchases = data.restoredData;

// In RevenueCat, create a single "premium" entitlement and link both your iOS
// and Android products to it. entitlementId will be "premium" on both platforms.
const hasPremium = purchases.some(p => p.isActive && p.entitlementId === "premium");

// One-time purchases use an exact match
const hasNoAds = purchases.some(p => p.isActive && p.entitlementId === "no_ads");

if (hasPremium) {
  // Grant premium features
}
```

## Testing Your Integration

<Danger>
  **AI cannot test payment integrations for you.** RevenueCat, native StoreKit/Google Billing, webhooks, and your backend are cross-platform processes that require real end-to-end testing with multiple accounts and devices. There is no shortcut here - a payment integration is one of the most critical parts of your app and must be thoroughly verified by you before going live.
</Danger>

Here's what you should test manually:

**Web -> Mobile**

1. Log in to your web app and complete a purchase via the Web Purchase Link
2. Open the mobile app logged in with the same account
3. Verify entitlements are active and premium features are unlocked

**Mobile -> Web**

1. Complete a purchase on the mobile app via TestFlight (no real money - use sandbox test accounts)
2. Open your web app logged in with the same account
3. Verify your backend has the correct subscription state

**Webhooks**

1. Go to **RevenueCat Dashboard -> Integrations -> Webhooks**
2. Check the event log - verify events are arriving at your server
3. Confirm your database is updating `is_active`, `subscription_status`, and `expires_at` correctly for each event type
4. Test `EXPIRATION` and `CANCELLATION` events to make sure access is revoked properly

**Restore Purchases**

1. Delete and reinstall the app
2. Tap the Restore Purchases button
3. Verify native IAP entitlements are recovered correctly

<Note>
  TestFlight and sandbox purchases use test accounts and never charge real money. Always test on TestFlight before submitting to the App Store. See [Apple's sandbox testing guide](https://developer.apple.com/documentation/storekit/in-app_purchase/testing_in-app_purchases_with_sandbox) and [Google Play's test purchases guide](https://developer.android.com/google/play/billing/test) for setup instructions.
</Note>

Payment integrations are complex and critical for most apps. We hope this documentation and our examples make it easier and provide helpful guidance - but a carefully and successfully tested setup by you is the only way to ship with confidence.

## Resources

* [**NPM Package**](https://www.npmjs.com/package/despia-native)
* [RevenueCat Webhooks Template](https://setup.despia.com/best-practices/backend/revenue-cat/webhooks)
* [RevenueCat Webhooks](https://www.revenuecat.com/docs/integrations/webhooks)
* [Webhook Event Types & Fields](https://www.revenuecat.com/docs/integrations/webhooks/event-types-and-fields)
* [Sample Webhook Events](https://www.revenuecat.com/docs/integrations/webhooks/sample-events)

For additional support or questions, please contact our support team at [**support@despia.com**](mailto:support@despia.com)
