## Overview

Flex integrates with RevenueCat to help you manage subscriptions seamlessly across platforms. This guide walks you through connecting your RevenueCat account to Flex, passing a stable customer identifier, saving the integration, and understanding how events map between systems.

---

## Installation Guide

Flex + RevenueCat Integration - YouTube  
[Flex + RevenueCat Integration](https://www.youtube.com/watch?v=SD5QBFrlHuE)

1. **Add RevenueCat in Flex**  
**Log in** to your **Flex Dashboard**. Go to **Integrations → RevenueCat**. Enter your **RevenueCat App ID** and **API Key**.  
   - You can find these in your RevenueCat project under **App Settings**.

2. **Configure in RevenueCat**  
In your **RevenueCat** project, open the **Apps** section. Click **\+ Add** and select **Other payment provider**. Copy your **App ID** and **Secret API Key**. Paste these values into the Flex Dashboard RevenueCat fields from Step 1.

If you **don’t see “Other payment provider (External source)”** in your RevenueCat **Apps** page, that option may not be enabled for your project. Please contact **RevenueCat Support** and ask them to enable the **External payment provider** integration for your project. Include:  
   - Your **Project ID / link**  
   - The **App name**  
   - That you want to connect **Flex** as the external payment provider

3. **Pass a Custom User Identifier to RevenueCat**  
If you want RevenueCat to reference your own user ID (or a Flex user ID), include it in the **checkout session** `metadata` as `REVENUE_CAT_CUSTOMER_ID`.
   
   ```json
   {
     "checkout_session": {
       "line_items": [
         {
           "price": "fprice_01k2j2zfnbrs5e0r4c8wqphv29",
           "quantity": 1
         }
       ],
       "success_url": "https://example.com/thank-you?success=true",
       "mode": "subscription",
       "cancel_url": "https://example.com/thank-you?canceled=true",
       "shipping_address_collection": false,
       "defaults": {
         "first_name": "Varsha",
         "last_name": "Parthay",
         "email": "rclivedemo1@gmail.com"
       },
       "metadata": {
         "REVENUE_CAT_CUSTOMER_ID": "1234567890"
       }
     }
   }
   ```

4. **Save and Sync**  
Click **Save** in the Flex Dashboard integration page. Flex will begin sending subscription events to RevenueCat so customer status stays in sync.  
   > **Tip:** If you have existing subscriptions, run an **initial sync** (or perform a backfill) so historical subscriptions are recognized in RevenueCat.

---

## How Flex Integrates with RevenueCat

There are two ways subscriptions can flow into RevenueCat:

### Native Stripe Integration

Stripe sends webhooks directly to RC. RC processes them and generates events with store: "STRIPE".

### Flex External Purchases API

Flex processes payments via Stripe, then syncs subscription state to RC via the External Purchases API (`POST /v1/receipts/external`). RC generates events with store: "EXTERNAL".

For Flex-managed subscriptions, **all events** come through the External Purchases API path. The subscription data is accurate and revenue is correctly tracked — but certain webhook fields have different values than a native Stripe integration would produce.

**If you’re migrating from a native Stripe → RevenueCat integration**, be aware of these key differences:

- **`store` is "EXTERNAL"**, not "STRIPE" — update any webhook consumers that filter by store
- **Refund events have `price: 0.0`**, never negative — use the RC Subscriber API or Flex API for refund amounts
- **`expiration_reason` is always `null`** — use `cancel_reason` on preceding CANCELLATION events instead
- **Prorated refunds are invisible in webhooks** — no `CUSTOMER_SUPPORT` event is generated; use Flex API to detect partial refunds
- **IDs use Flex format** (`fprod_`, `fsub_`, `fcus_`) instead of Stripe format (`prod_`, `si_`, `cus_`) — treat all IDs as opaque strings

These differences are inherent to the RevenueCat External Purchases API and have been confirmed with the RevenueCat team (March 2026). Revenue is always accurately tracked inside RevenueCat — these differences only affect webhook payload fields.

---

## Subscription Lifecycle Events

Flex syncs the following RevenueCat event types. This is the complete list — every event type that Flex sends to RC:

| Flex Internal Event | RC Webhook Event Type | Description | Includes Payment? |
| --- | --- | --- | --- |
| `TrialPurchase` | `INITIAL_PURCHASE` | Free trial started ($0 invoice). Subscription created with status: trialing. | No |
| `TrialConversion` | `INITIAL_PURCHASE` | Trial converted to paid subscription. First real payment processed. | Yes (positive amount) |
| `Renewal` | `RENEWAL` | Subscription renewed at start of a new billing period. | Yes (positive amount) |
| `BillingSucceeds` | `RENEWAL` | Payment recovered after a past_due period. Same logic as Renewal. | Yes (positive amount) |
| `BillingIssue` | `BILLING_ISSUE` | Payment failed during renewal. Subscription enters grace period. | No |
| `Expiration` | `EXPIRATION` | Subscription access ended. Triggered by Stripe `customer.subscription.deleted`. | No |
| `CancelAtPeriodEnd` | `CANCELLATION` | Subscription set to not renew at end of current period. Still active. | No |
| `UndoCancelAtPeriodEnd` | `UNCANCELLATION` | Cancel-at-period-end reversed. Subscription will renew again. | No |
| `Refund` | `CANCELLATION` | Refund issued. Sends expired status + negative payment to adjust revenue. | Yes (negative amount) |

`ProductChange` (upgrades/downgrades) is **not currently supported**. All other subscription lifecycle events are fully implemented and synced in real-time.

---

## Stripe Trigger Mapping

Each Flex RC sync event is triggered by a specific Stripe webhook event:

| Stripe Webhook Event | Condition | Flex RC Event Type |
| --- | --- | --- |
| `invoice.payment_succeeded` | `amount_paid == 0` AND `status == trialing` | `TrialPurchase` |
| `invoice.payment_succeeded` | `status == trialing` AND `amount > 0` | `TrialConversion` |
| `invoice.payment_succeeded` | `billing_reason == subscription_create` | `TrialConversion` |
| `invoice.payment_succeeded` | `status == active`, billing cycle | `Renewal` |
| `invoice.payment_succeeded` | `status == past_due` | `BillingSucceeds` |
| `invoice.payment_failed` | Any | `BillingIssue` |
| `customer.subscription.updated` | `cancel_at_period_end`: false → true | `CancelAtPeriodEnd` |
| `customer.subscription.updated` | `cancel_at_period_end`: true → false | `UndoCancelAtPeriodEnd` |
| `customer.subscription.deleted` | Any | `Expiration` |
| `charge.refund.updated` | `refund status == succeeded` | `Refund` |

---

## Event Sequences by Scenario

### New Subscription (No Trial)

| # | RC Event Type | cancel_reason | price | gives_access |
| --- | --- | --- | --- | --- |
| 1 | `INITIAL_PURCHASE` | — | $179.99 | true |

### New Subscription (With Free Trial)

| # | RC Event Type | Trigger | price | status |
| --- | --- | --- | --- | --- |
| 1 | `INITIAL_PURCHASE` | Trial starts ($0 invoice) | $0.00 | trialing |
| 2 | `INITIAL_PURCHASE` | Trial converts (first payment) | $179.99 | active |

Both events appear as `INITIAL_PURCHASE` in RC webhooks. The first has price `0` and status `trialing`; the second has the actual price and status `active`.

### Renewal

| # | RC Event Type | price | gives_access |
| --- | --- | --- | --- |
| 1 | `RENEWAL` | $179.99 | true |

### Payment Failure → Recovery

| # | RC Event Type | Trigger | status | gives_access |
| --- | --- | --- | --- | --- |
| 1 | `BILLING_ISSUE` | Payment fails | in_grace_period | true (grace period) |
| 2 | `RENEWAL` | Payment retried and succeeds | active | true |

If payment never recovers, the subscription eventually receives an `EXPIRATION` event when Stripe deletes it.

### Cancel at End of Billing Period (Option A)

| # | RC Event Type | cancel_reason | gives_access | auto_renewal_status |
| --- | --- | --- | --- | --- |
| 1 | `CANCELLATION` | `UNSUBSCRIBE` | true | will_not_renew |

Subscription remains active until the current period ends. Access continues during this time.

### Undo Cancel / Re-subscribe Before Period Ends

| # | RC Event Type | gives_access | auto_renewal_status |
| --- | --- | --- | --- |
| 1 | `UNCANCELLATION` | true | will_renew |

### Cancel Immediately + Full Refund (Option B)

| # | RC Event Type | cancel_reason / expiration_reason | price | gives_access |
| --- | --- | --- | --- | --- |
| 1 | `CANCELLATION` | cancel_reason: `UNSUBSCRIBE` | $0.00 | true |
| 2 | `EXPIRATION` | expiration_reason: null | $0.00 | false |
| 3 | `CANCELLATION` | cancel_reason: `CUSTOMER_SUPPORT` | $0.00 | false |

Event #3 is the refund signal. In a native Stripe integration, price would be -179.99. With the External API, the price is always `0.00`. Revenue **IS** correctly adjusted inside RC — query the Subscriber API to confirm net revenue.

### Cancel Immediately + Prorated Refund (Option C)

| # | RC Event Type | cancel_reason / expiration_reason | price | gives_access |
| --- | --- | --- | --- | --- |
| 1 | `EXPIRATION` | expiration_reason: null | $0.00 | false |
| 2 | `CANCELLATION` | cancel_reason: `UNSUBSCRIBE` | $0.00 | true |

No `CANCELLATION` (`CUSTOMER_SUPPORT`) event is generated for prorated refunds. The refund is invisible in webhooks. Revenue is adjusted internally in RC. Use the Flex API or RC Subscriber API for refund details.

### Subscription Expires Naturally (After Cancel at Period End)

| # | RC Event Type | expiration_reason | gives_access |
| --- | --- | --- | --- |
| 1 | `EXPIRATION` | null | false |

This fires when the billing period ends after a `CancelAtPeriodEnd` was set.

---

## Field Reference

Every RC webhook event from Flex will have these field values:

| Field | Value | Notes |
| --- | --- | --- |
| `store` | "EXTERNAL" | Always EXTERNAL for Flex-managed subscriptions |
| `environment` | "SANDBOX" or "PRODUCTION" | Matches the Flex partner’s test_mode setting |
| `product_id` | `fprod_xxx` | Flex product ID (not a Stripe product ID) |
| `transaction_id` | `fsub_xxx` | Flex subscription ID |
| `original_transaction_id` | `fsub_xxx` | Same as transaction_id |
| `app_user_id` | `fcus_xxx` | Flex customer ID |
| `currency` | `USD` | ISO 4217 currency code |
| `price` | `0.0` or positive amount | Never negative — see [Refund Handling](https://docs.withflex.com/developer-guides/integration/revenuecat#refund-handling) |
| `price_in_purchased_currency` | Same as price | Always matches price |
| `commission_percentage` | `0.0` | Flex handles its own payment processing |
| `takehome_percentage` | `1.0` | Flex handles its own payment processing |
| `tax_percentage` | `0.0` | Tax reported as zero |
| `country_code` | `null` | Not available via External API |
| `entitlement_ids` | `null` or populated | Depends on RC project entitlement configuration |
| `renewal_number` | `1` | Always 1 — External API limitation |
| `is_family_share` | `false` | Not applicable |
| `period_type` | "NORMAL" | Always NORMAL |
| `subscriber_attributes` | `{}` | Empty unless custom attributes configured |
| `metadata` | `null` | Not populated |
| `expiration_reason` | `null` | Never populated for External store |
| `cancel_reason` | "UNSUBSCRIBE" or "CUSTOMER_SUPPORT" | UNSUBSCRIBE = user-initiated cancel. CUSTOMER_SUPPORT = refund. |

---

## Key Differences from Stripe-Native Integration

If your webhook consumer was originally built for a native Stripe → RevenueCat integration, the following differences apply when receiving events from Flex. These are inherent to the RevenueCat External Purchases API and have been confirmed with the RevenueCat team (March 2026).

| Field | Stripe-Native Value | Flex External Value | Why | Impact |
| --- | --- | --- | --- | --- |
| `store` | "STRIPE" | "EXTERNAL" | RC sets this based on app type | Must accept both values |
| `price` (refund) | Negative (e.g. `-179.99`) | Always `0.0` | External API does not expose payment amounts in webhook price field | Cannot detect refund amount from webhook |
| `price_in_purchased_currency` (refund) | Negative | Always `0.0` | Same as above | Same as above |
| `expiration_reason` | "UNSUBSCRIBE" | `null` | External API does not set this field | Accept null as valid |
| `commission_%` | ~0.03 (Stripe fee) | `0.0` | Flex handles its own payment processing | Informational only |
| `takehome_%` | ~0.97 | `1.0` | Derived from zero commission | Informational only |
| `product_id` | `prod_xxx` (Stripe) | `fprod_xxx` (Flex) | Flex uses its own product IDs | Treat as opaque string |
| `transaction_id` | `si_xxx` (Stripe sub item) | `fsub_xxx` (Flex sub) | Flex uses its own subscription IDs | Treat as opaque string |
| `app_user_id` | `cus_xxx` (Stripe customer) | `fcus_xxx` (Flex customer) | Flex uses its own customer IDs | Treat as opaque string |
| `renewal_number` | Increments each period | Always `1` | External API does not track renewal count | Track externally if needed |

These differences are **not bugs**. They are confirmed RevenueCat platform behavior for the External Purchases API. Flex sends all available data correctly; the External API simply exposes different fields than native integrations.

---

## Refund Handling

Refund visibility in RC webhooks depends on the refund type and flow:

| Scenario | RC Webhook Event | cancel_reason | price | Revenue Adjusted in RC? |
| --- | --- | --- | --- | --- |
| Full refund (immediate cancel + refund) | `CANCELLATION` | `CUSTOMER_SUPPORT` | `0.0` (not negative) | Yes — gross = $0.00 |
| Prorated refund | No specific refund event | N/A | N/A | Yes — gross = net amount |
| Custom refund amount | No specific refund event | N/A | N/A | Yes — gross = net amount |
| Cancel at period end then later refund | May not generate `CUSTOMER_SUPPORT` | N/A | N/A | Yes — always adjusted |

### How to detect and measure refunds

| Method | How | Detects Full Refunds? | Detects Prorated Refunds? | Gets Refund Amount? |
| --- | --- | --- | --- | --- |
| RC Webhook | `cancel_reason == "CUSTOMER_SUPPORT"` | Yes (2-step flow only) | No | No (price is 0.0) |
| RC Subscriber API | `GET /v1/subscribers/{id}` → check `total_revenue_in_usd` | Yes | Yes | Yes (net revenue) |
| Flex API / Webhooks | Flex refund events with amounts, types, timestamps | Yes | Yes | Yes (exact amount) |

**Recommended:** Use Flex webhooks for refund tracking (exact amounts and types) and RC webhooks for subscription lifecycle (active, canceled, expired, billing).

---

## Recommendations for Webhook Consumers

### Handling the store field

Accept events where store is either "STRIPE" or "EXTERNAL":

```python
if event.store in ["STRIPE", "EXTERNAL"]:
    process_event(event)
```

### Detecting subscription state changes

Use the event type and `cancel_reason` to determine subscription state:

| Condition | Meaning |
| --- | --- |
| `type == INITIAL_PURCHASE` | New subscription or trial conversion |
| `type == RENEWAL` | Successful renewal payment or billing recovery |
| `type == CANCELLATION` AND `cancel_reason == UNSUBSCRIBE` | User canceled (won’t renew, still active) |
| `type == CANCELLATION` AND `cancel_reason == CUSTOMER_SUPPORT` | Refund issued (full refund only) |
| `type == UNCANCELLATION` | Cancel reversed, will renew again |
| `type == EXPIRATION` | Access revoked, subscription ended |
| `type == BILLING_ISSUE` | Payment failed, in grace period |

### Handling refunds

1. Detect refund occurrence  
   Check for `cancel_reason == "CUSTOMER_SUPPORT"` on CANCELLATION events.
2. Do NOT rely on negative price  
   The price will always be `0.0` for External store events.
3. Get refund amounts  
   Use the Flex API or RC Subscriber API for exact refund amounts.
4. Handle prorated/custom refunds  
   No RC webhook event is generated for these — use Flex webhooks instead.

### Handling expiration events

Accept `expiration_reason: null`. The External store does not populate this field. To determine why a subscription expired, look at the preceding CANCELLATION event’s `cancel_reason`.

### ID formats

Treat all IDs as opaque strings. Do not parse, validate format, or assume a prefix:

| ID Field | Stripe-Native Format | Flex Format |
| --- | --- | --- |
| `product_id` | `prod_xxx` | `fprod_xxx` |
| `transaction_id` | `si_xxx` | `fsub_xxx` |
| `original_transaction_id` | `si_xxx` | `fsub_xxx` |
| `app_user_id` | `cus_xxx` | `fcus_xxx` |

---

## Example Payloads

All payloads below are from real webhook captures in the RevenueCat sandbox.  
- INITIAL_PURCHASE (Trial Start)  
- INITIAL_PURCHASE (Conversion)  
- RENEWAL  
- CANCELLATION (User)  
- CANCELLATION (Refund)  
- UNCANCELLATION  
- EXPIRATION  
- BILLING_ISSUE

```json
{
  "api_version": "1.0",
  "event": {
    "type": "INITIAL_PURCHASE",
    "store": "EXTERNAL",
    "environment": "SANDBOX",
    "product_id": "fprod_xxx",
    "transaction_id": "fsub_xxx",
    "original_transaction_id": "fsub_xxx",
    "app_user_id": "fcus_xxx",
    "currency": "USD",
    "price": 0.0,
    "price_in_purchased_currency": 0.0,
    "period_type": "NORMAL",
    "renewal_number": 1,
    "is_family_share": false,
    "commission_percentage": 0.0,
    "takehome_percentage": 1.0,
    "tax_percentage": 0.0,
    "country_code": null,
    "entitlement_ids": null,
    "expiration_reason": null,
    "cancel_reason": null,
    "subscriber_attributes": {}
  }
}
```

```json
{
  "api_version": "1.0",
  "event": {
    "type": "INITIAL_PURCHASE",
    "store": "EXTERNAL",
    "environment": "SANDBOX",
    "product_id": "fprod_xxx",
    "transaction_id": "fsub_xxx",
    "app_user_id": "fcus_xxx",
    "currency": "USD",
    "price": 179.99,
    "price_in_purchased_currency": 179.99,
    "commission_percentage": 0.0,
    "takehome_percentage": 1.0,
    "tax_percentage": 0.0,
    "renewal_number": 1
  }
}
```

```json
{
  "api_version": "1.0",
  "event": {
    "type": "RENEWAL",
    "store": "EXTERNAL",
    "product_id": "fprod_xxx",
    "app_user_id": "fcus_xxx",
    "currency": "USD",
    "price": 179.99,
    "price_in_purchased_currency": 179.99,
    "commission_percentage": 0.0,
    "takehome_percentage": 1.0,
    "renewal_number": 1
  }
}
```

```json
{
  "api_version": "1.0",
  "event": {
    "type": "CANCELLATION",
    "cancel_reason": "UNSUBSCRIBE",
    "store": "EXTERNAL",
    "product_id": "fprod_xxx",
    "app_user_id": "fcus_xxx",
    "currency": "USD",
    "price": 0.0,
    "price_in_purchased_currency": 0.0,
    "commission_percentage": 0.0,
    "takehome_percentage": 1.0
  }
}
```

```json
{
  "api_version": "1.0",
  "event": {
    "type": "CANCELLATION",
    "cancel_reason": "CUSTOMER_SUPPORT",
    "store": "EXTERNAL",
    "product_id": "fprod_xxx",
    "app_user_id": "fcus_xxx",
    "currency": "USD",
    "price": 0.0,
    "price_in_purchased_currency": 0.0,
    "commission_percentage": 0.0,
    "takehome_percentage": 1.0
  }
}
```

Price is `0.0`, not negative. Revenue is adjusted inside RC but not reflected in this webhook payload.

```json
{
  "api_version": "1.0",
  "event": {
    "type": "UNCANCELLATION",
    "store": "EXTERNAL",
    "product_id": "fprod_xxx",
    "app_user_id": "fcus_xxx",
    "currency": "USD",
    "price": 0.0,
    "commission_percentage": 0.0,
    "takehome_percentage": 1.0
  }
}
```

```json
{
  "api_version": "1.0",
  "event": {
    "type": "EXPIRATION",
    "expiration_reason": null,
    "store": "EXTERNAL",
    "product_id": "fprod_xxx",
    "app_user_id": "fcus_xxx",
    "currency": "USD",
    "price": 0.0,
    "price_in_purchased_currency": 0.0,
    "commission_percentage": 0.0,
    "takehome_percentage": 1.0
  }
}
```

```json
{
  "api_version": "1.0",
  "event": {
    "type": "BILLING_ISSUE",
    "store": "EXTERNAL",
    "product_id": "fprod_xxx",
    "app_user_id": "fcus_xxx",
    "currency": "USD",
    "price": 0.0,
    "commission_percentage": 0.0,
    "takehome_percentage": 1.0
  }
}
```
