# Webhooks

Use webhooks to get real-time notifications about your app's subscription and payment events.

In the **Webhooks** section within **Integrations**, you can manage your webhooks with Superwall:

<img src="__img0" />

Webhooks [#webhooks]

Superwall sends webhooks to notify your application about important subscription and payment events in real-time. These webhooks are designed to closely match App Store and other revenue provider events, minimizing migration difficulty.

**Important Design Principle**: Webhook events are structured so that summing `proceeds` or `price` across all events (without filtering) accurately represents total revenue net of refunds. To calculate gross revenue, filter out events with negative proceeds.

Webhook Payload Structure [#webhook-payload-structure]

Every webhook sent by Superwall contains the following structure:

```json
{
  "object": "event",
  "type": "renewal",
  "projectId": 3827,
  "applicationId": 1,
  "timestamp": 1754067715103,
  "data": {
    "id": "42fc6339-dc28-470b-a0fa-0d13c92d8b61:renewal",
    "name": "renewal",
    "cancelReason": null,
    "exchangeRate": 1.0,
    "isSmallBusiness": false,
    "periodType": "NORMAL",
    "countryCode": "US",
    "price": 9.99,
    "proceeds": 6.99,
    "priceInPurchasedCurrency": 9.99,
    "taxPercentage": 0,
    "commissionPercentage": 0.3,
    "takehomePercentage": 0.7,
    "offerCode": null,
    "isFamilyShare": false,
    "expirationAt": 1756659704000,
    "transactionId": "700002054157982",
    "originalTransactionId": "700002050981465",
    "originalAppUserId": "$SuperwallAlias:7152E89E-60A6-4B2E-9C67-D7ED8F5BE372",
    "store": "APP_STORE",
    "purchasedAt": 1754067704000,
    "currencyCode": "USD",
    "productId": "com.example.premium.monthly",
    "environment": "PRODUCTION",
    "isTrialConversion": false,
    "newProductId": null,
    "bundleId": "com.example.app",
    "ts": 1754067710106
  }
}
```

Webhook Payload Fields [#webhook-payload-fields]

| Field           | Type   | Description                                                           |
| --------------- | ------ | --------------------------------------------------------------------- |
| `object`        | string | Always "event"                                                        |
| `type`          | string | The event type (e.g., "initial\_purchase", "renewal", "cancellation") |
| `projectId`     | number | Your Superwall project ID                                             |
| `applicationId` | number | Your Superwall application ID                                         |
| `timestamp`     | number | Event timestamp in milliseconds since epoch                           |
| `data`          | object | Event-specific data (see below)                                       |

Event Data Object [#event-data-object]

The `data` field contains detailed information about the subscription or payment event:

Event Data Fields [#event-data-fields]

| Field                      | Type              | Description                                                                                   |
| -------------------------- | ----------------- | --------------------------------------------------------------------------------------------- |
| `id`                       | string            | Unique identifier for this event                                                              |
| `name`                     | string            | Event name (see [Event Names](#event-names))                                                  |
| `cancelReason`             | string or null    | Reason for cancellation (see [Cancel Reasons](#cancelexpiration-reasons))                     |
| `exchangeRate`             | number            | Exchange rate used to convert to USD                                                          |
| `isSmallBusiness`          | boolean           | Small business program participant                                                            |
| `periodType`               | string            | Period type: `TRIAL`, `INTRO`, or `NORMAL`                                                    |
| `countryCode`              | string            | ISO country code (e.g., "US")                                                                 |
| `price`                    | number            | Transaction price in USD (negative for refunds)                                               |
| `proceeds`                 | number            | Net proceeds in USD after taxes and fees                                                      |
| `priceInPurchasedCurrency` | number            | Price in original currency                                                                    |
| `taxPercentage`            | number or null    | Tax percentage applied                                                                        |
| `commissionPercentage`     | number            | Store commission percentage                                                                   |
| `takehomePercentage`       | number            | Your percentage after commission                                                              |
| `offerCode`                | string or null    | Promotional offer code used                                                                   |
| `isFamilyShare`            | boolean           | Family sharing purchase                                                                       |
| `expirationAt`             | number or null    | Expiration timestamp (milliseconds)                                                           |
| `transactionId`            | string            | Current transaction ID                                                                        |
| `originalTransactionId`    | string            | Original transaction ID (subscription ID)                                                     |
| `originalAppUserId`        | string or null    | Original app user ID — requires SDK v4.5.2+ (see [details](#understanding-originalappuserid)) |
| `store`                    | string            | Store: `APP_STORE`, `PLAY_STORE`, or `STRIPE` (see note below)                                |
| `purchasedAt`              | number            | Purchase timestamp (milliseconds)                                                             |
| `currencyCode`             | string            | ISO currency code for priceInPurchasedCurrency                                                |
| `productId`                | string            | Product identifier                                                                            |
| `environment`              | string            | `PRODUCTION` or `SANDBOX`                                                                     |
| `isTrialConversion`        | boolean           | Trial to paid conversion                                                                      |
| `newProductId`             | string or null    | New product ID (for product changes)                                                          |
| `bundleId`                 | string            | App bundle identifier                                                                         |
| `ts`                       | number            | Event timestamp (milliseconds)                                                                |
| `expirationReason`         | string (optional) | Reason for expiration (see [Cancel Reasons](#cancelexpiration-reasons))                       |
| `checkoutContext`          | object (optional) | Stripe-specific checkout context                                                              |
| `userAttributes`           | object (optional) | Custom user attributes set via the SDK (see [User Attributes](#user-attributes))              |

**Note on Store field:** iOS and Android apps can receive events from any payment provider. For example, an iOS app can receive `STRIPE` events when users purchase through Superwall's App2Web features, which can start from a mobile paywall and complete in an external browser. The `store` field indicates where the payment was processed, not which platform the app runs on.

Event Names [#event-names]

| Event Name            | Value                   | Description                         |
| --------------------- | ----------------------- | ----------------------------------- |
| Initial Purchase      | `initial_purchase`      | First-time subscription or purchase |
| Renewal               | `renewal`               | Subscription renewal                |
| Cancellation          | `cancellation`          | Subscription cancelled              |
| Uncancellation        | `uncancellation`        | Subscription reactivated            |
| Expiration            | `expiration`            | Subscription expired                |
| Billing Issue         | `billing_issue`         | Payment processing failed           |
| Product Change        | `product_change`        | User changed subscription tier      |
| Subscription Paused   | `subscription_paused`   | Subscription temporarily paused     |
| Non-Renewing Purchase | `non_renewing_purchase` | One-time purchase                   |

Period Types [#period-types]

| Period Type | Value    | Description                                 |
| ----------- | -------- | ------------------------------------------- |
| Trial       | `TRIAL`  | Free trial period                           |
| Intro       | `INTRO`  | Introductory offer period (discounted rate) |
| Normal      | `NORMAL` | Regular subscription period (full price)    |

Stores [#stores]

| Store      | Value        | Description       |
| ---------- | ------------ | ----------------- |
| App Store  | `APP_STORE`  | Apple App Store   |
| Play Store | `PLAY_STORE` | Google Play Store |
| Stripe     | `STRIPE`     | Stripe payments   |

Environments [#environments]

| Environment | Value        | Description                           |
| ----------- | ------------ | ------------------------------------- |
| Production  | `PRODUCTION` | Live production transactions          |
| Sandbox     | `SANDBOX`    | Sandbox transactions (not real money) |

Cancel/Expiration Reasons [#cancelexpiration-reasons]

| Reason              | Value                 | Description                   |
| ------------------- | --------------------- | ----------------------------- |
| Billing Error       | `BILLING_ERROR`       | Payment method failed         |
| Customer Support    | `CUSTOMER_SUPPORT`    | Cancelled via support         |
| Unsubscribe         | `UNSUBSCRIBE`         | User-initiated cancellation   |
| Price Increase      | `PRICE_INCREASE`      | Cancelled due to price change |
| Developer Initiated | `DEVELOPER_INITIATED` | Cancelled programmatically    |
| Unknown             | `UNKNOWN`             | Reason not specified          |

Common Use Cases [#common-use-cases]

Detecting Trial Starts [#detecting-trial-starts]

```javascript
if (
  event.data.periodType === "TRIAL" &&
  event.data.name === "initial_purchase"
) {
  // New trial started
}
```

Detecting Trial Conversions [#detecting-trial-conversions]

```javascript
if (
  event.data.name === "renewal" &&
  (event.data.isTrialConversion ||
    event.data.periodType === "TRIAL" ||
    event.data.periodType === "INTRO")
) {
  // Trial or intro offer converted to paid subscription
}
```

Detecting Trial Cancellations [#detecting-trial-cancellations]

```javascript
if (event.data.periodType === "TRIAL" && event.data.name === "cancellation") {
  // Trial cancelled
}
```

Detecting Trial Uncancellations (Reactivations) [#detecting-trial-uncancellations-reactivations]

```javascript
if (event.data.periodType === "TRIAL" && event.data.name === "uncancellation") {
  // Trial reactivated after cancellation
}
```

Detecting Trial Expirations [#detecting-trial-expirations]

```javascript
if (event.data.periodType === "TRIAL" && event.data.name === "expiration") {
  // Trial expired
}
```

Detecting Intro Offer Starts [#detecting-intro-offer-starts]

```javascript
if (
  event.data.periodType === "INTRO" &&
  event.data.name === "initial_purchase"
) {
  // Intro offer started
}
```

Detecting Intro Offer Cancellations [#detecting-intro-offer-cancellations]

```javascript
if (event.data.periodType === "INTRO" && event.data.name === "cancellation") {
  // Intro offer cancelled
}
```

Detecting Intro Offer Uncancellations [#detecting-intro-offer-uncancellations]

```javascript
if (event.data.periodType === "INTRO" && event.data.name === "uncancellation") {
  // Intro offer reactivated
}
```

Detecting Intro Offer Expirations [#detecting-intro-offer-expirations]

```javascript
if (event.data.periodType === "INTRO" && event.data.name === "expiration") {
  // Intro offer expired
}
```

Detecting Intro Offer Conversions [#detecting-intro-offer-conversions]

```javascript
if (event.data.periodType === "INTRO" && event.data.name === "renewal") {
  // Intro offer converted to regular subscription
}
```

Detecting Subscription Starts [#detecting-subscription-starts]

```javascript
if (
  event.data.periodType === "NORMAL" &&
  event.data.name === "initial_purchase"
) {
  // New paid subscription started
}
```

Detecting Renewals [#detecting-renewals]

```javascript
if (
  event.data.name === "renewal" &&
  event.data.periodType === "NORMAL" &&
  !event.data.isTrialConversion
) {
  // Regular subscription renewal
}
```

Detecting Refunds [#detecting-refunds]

```javascript
if (event.data.price < 0) {
  // Refund processed
  const refundAmount = Math.abs(event.data.price);
}
```

Detecting Cancellations [#detecting-cancellations]

```javascript
if (event.data.name === "cancellation") {
  // Subscription cancelled
  // Check cancelReason for details
  const reason = event.data.cancelReason;
}
```

Detecting Subscription Expirations [#detecting-subscription-expirations]

```javascript
if (event.data.name === "expiration") {
  // Subscription expired
  // Check expirationReason for details
}
```

Detecting Billing Issues [#detecting-billing-issues]

```javascript
if (event.data.name === "billing_issue") {
  // Payment failed - subscription at risk
}
```

Detecting Subscription Pauses [#detecting-subscription-pauses]

```javascript
if (event.data.name === "subscription_paused") {
  // Subscription has been paused
}
```

Detecting Product Changes [#detecting-product-changes]

```javascript
if (event.data.name === "product_change") {
  // User changed subscription plan
  const oldProduct = event.data.productId;
  const newProduct = event.data.newProductId;
}
```

Detecting Subscription Reactivations [#detecting-subscription-reactivations]

```javascript
if (event.data.name === "uncancellation") {
  // Previously cancelled subscription was reactivated
}
```

Detecting Non-Renewing Purchases [#detecting-non-renewing-purchases]

```javascript
if (event.data.name === "non_renewing_purchase") {
  // One-time purchase completed
}
```

Detecting Revenue Events [#detecting-revenue-events]

```javascript
if (event.data.price !== 0 || event.data.name === "non_renewing_purchase") {
  // This event involves revenue (positive or negative)
}
```

Revenue Calculation [#revenue-calculation]

Total Net Revenue (Including Refunds) [#total-net-revenue-including-refunds]

```javascript
// Sum all proceeds - automatically accounts for refunds
const netRevenue = events.reduce((sum, event) => sum + event.data.proceeds, 0);
```

Gross Revenue (Excluding Refunds) [#gross-revenue-excluding-refunds]

```javascript
// Only sum positive proceeds
const grossRevenue = events.reduce(
  (sum, event) => (event.data.proceeds > 0 ? sum + event.data.proceeds : sum),
  0
);
```

Refund Total [#refund-total]

```javascript
// Sum negative proceeds
const refunds = events.reduce(
  (sum, event) =>
    event.data.proceeds < 0 ? sum + Math.abs(event.data.proceeds) : sum,
  0
);
```

Revenue by Product [#revenue-by-product]

```javascript
const revenueByProduct = {};
events.forEach((event) => {
  const productId = event.data.productId;
  if (!revenueByProduct[productId]) {
    revenueByProduct[productId] = 0;
  }
  revenueByProduct[productId] += event.data.proceeds;
});
```

Testing Webhooks [#testing-webhooks]

> **Warning**

iOS local StoreKit transactions (using a StoreKit Configuration file or StoreKitTest
in Xcode) do not generate App Store Server Notifications. As a result, Superwall
webhooks will not fire for these local test purchases. To verify webhook delivery on iOS,
use Sandbox via TestFlight with a sandbox Apple ID.



To test webhooks, trigger real events in sandbox:

* iOS: Use TestFlight with a sandbox Apple ID (StoreKit Configuration files do not trigger webhooks).
* Google Play: Use license test accounts for sandbox purchases.
* Stripe: Use Stripe Test Mode to create sandbox transactions.

Note: We do not support sending arbitrary "test" webhooks.

Best Practices [#best-practices]

1. **Handle duplicate events** - Use `event.id` for idempotency
2. **Process webhooks asynchronously** - Return 200 immediately, then process
3. **Store raw webhook data** for debugging and reconciliation
4. **Handle all event types** - Even if you don't process them immediately
5. **Monitor webhook failures** - Implement retry logic for critical events
6. **Use timestamps** - All timestamps are in milliseconds since epoch

Store-Specific Behaviors [#store-specific-behaviors]

Commission Rates by Store [#commission-rates-by-store]

**APP\_STORE:**

* Standard rate: 30%
* Small Business Program rate: 15% (for eligible developers)
* Clean, predictable commission structure

**PLAY\_STORE:**

* Variable rates from 11.8% to 15%
* Most common rate: 15%
* Rates can vary based on region and other factors

**STRIPE:**

* Variable rates from 0% to \~7.2%
* Generally lower than mobile app stores
* Depends on Stripe pricing plan and transaction type

Price = 0 Events [#price--0-events]

Events commonly have `price = 0` for non-revenue scenarios:

* `billing_issue` - Payment failed, no money collected
* `cancellation` - Subscription cancelled, no charge
* `expiration` - Subscription expired, no charge
* `uncancellation` - Reactivation, no immediate charge
* `product_change` - Plan change notification
* `subscription_paused` - Pause event, no charge

Revenue events (initial\_purchase, renewal, non\_renewing\_purchase) typically have non-zero prices unless:

* Family sharing scenario (some cases)
* Special promotional offers

Cancel/Expiration Reasons by Store [#cancelexpiration-reasons-by-store]

**APP\_STORE:**

* `CUSTOMER_SUPPORT` - Cancelled via Apple support
* `UNSUBSCRIBE` - User-initiated cancellation
* `BILLING_ERROR` - Payment failure

**PLAY\_STORE:**

* All APP\_STORE reasons plus:
* `UNKNOWN` - Reason not specified or unavailable

**STRIPE:**

* `UNKNOWN` - Stripe typically doesn't provide detailed cancellation reasons

Trial Conversions [#trial-conversions]

**Expected behavior:** `isTrialConversion` should only be `true` for `renewal` events

Offer Codes Support [#offer-codes-support]

| Store       | Support         | Notes                                                          |
| ----------- | --------------- | -------------------------------------------------------------- |
| APP\_STORE  | ✅ Supported     | Rarely used (1.3% of events), typically for win-back campaigns |
| PLAY\_STORE | ✅ Supported     | Heavily used (72.1% of events), complex promotional system     |
| STRIPE      | ❌ Not supported | Offer codes not available in webhook data                      |

Environment Field [#environment-field]

All stores support both PRODUCTION and SANDBOX environments:

* **PRODUCTION**: Live, real-money transactions
* **SANDBOX**: Sandbox transactions (TestFlight on iOS, Stripe Test Mode, Play Store test purchases)

The environment field helps you filter out sandbox transactions from production analytics.

Store Event Compatibility Matrix [#store-event-compatibility-matrix]

Not all events are available for all stores. This table shows which events you can expect from each store based on real webhook data:

Event Support by Store [#event-support-by-store]

| Event Name              | APP\_STORE | PLAY\_STORE | STRIPE |
| ----------------------- | ---------- | ----------- | ------ |
| `billing_issue`         | ✅          | ✅           | ✅      |
| `cancellation`          | ✅          | ✅           | ✅      |
| `expiration`            | ✅          | ✅           | ✅      |
| `initial_purchase`      | ✅          | ✅           | ✅      |
| `non_renewing_purchase` | ✅          | ✅           | ❌      |
| `product_change`        | ✅          | ✅           | ❌      |
| `renewal`               | ✅          | ✅           | ✅      |
| `subscription_paused`   | ❌          | ✅           | ❌      |
| `uncancellation`        | ✅          | ✅           | ✅      |

✅ = Supported | ❌ = Not supported

Period Type Availability by Store [#period-type-availability-by-store]

Different stores support different period types for events:

APP_STORE [#app_store]

* Supports all period types (TRIAL, INTRO, NORMAL) for most events
* `non_renewing_purchase` only occurs with NORMAL period type

PLAY_STORE [#play_store]

* Supports all period types (TRIAL, INTRO, NORMAL) for most events
* `renewal` only occurs with NORMAL period type
* `subscription_paused` only occurs with INTRO and NORMAL period types
* **Unique**: Only store that supports `subscription_paused` events

STRIPE [#stripe]

* Limited period type support compared to mobile app stores
* No INTRO period type support observed
* `expiration` and `renewal` only occur with NORMAL period type
* Does not support `non_renewing_purchase` or `product_change` events

Store-Specific Considerations [#store-specific-considerations]

**Universal Events** (available across APP\_STORE, PLAY\_STORE, and STRIPE):

* `billing_issue`
* `cancellation`
* `expiration`
* `initial_purchase`
* `renewal`
* `uncancellation`

**Store-Specific Events**:

* `subscription_paused` - Only available from PLAY\_STORE
* `non_renewing_purchase` - Not available from STRIPE
* `product_change` - Not available from STRIPE

Understanding originalAppUserId [#understanding-originalappuserid]

The `originalAppUserId` field represents the first app user ID associated with a subscription. This field has specific behavior depending on your integration:

> **Warning**

This field is only set correctly for events generated by users on SDK v4.5.2+.
Events from older SDK versions may omit this field or populate it inconsistently.



Key Points: [#key-points]

* **What it represents**: The first user ID we saw associated with this subscription (originalTransactionId)
* **Cross-account subscriptions**: Since subscriptions are tied to Apple/Google accounts (not app accounts), users can create multiple accounts in your app while using the same subscription
* **We only store the first one**: If a user creates multiple accounts, we only track the original user ID

When this field is populated: [#when-this-field-is-populated]

* **iOS/App Store**:
  * If your user ID has been sent to the stores on-device (via StoreKit)
  * If your user IDs are UUIDv4 format
  * This field will be consistently present for these cases
* **Stripe**: Always populated (we create one for you if not provided)
* **Play Store**: Depends on the integration and user tracking

When this field is null: [#when-this-field-is-null]

* **Legacy users**: Users on old SDK versions
* **Pre-Superwall purchases**: Users who purchased before integrating Superwall
* **No user ID sent**: If user ID was never sent to the store

Understanding originalTransactionId [#understanding-originaltransactionid]

The `originalTransactionId` is Apple's terminology that acts like a subscription ID. For simplicity and consistency with iOS and other revenue tracking platforms, we use this nomenclature and populate it accordingly for all platforms (Play Store, Stripe, etc.).

* **One per subscription group**: Each user subscription gets one `originalTransactionId`
* **Persists across renewals**: The same `originalTransactionId` is used for all renewals in that subscription
* **Multiple IDs per user**: A single user can have multiple `originalTransactionId` if they:
  * Subscribe to products in different subscription groups
  * Let a subscription fully expire and re-subscribe later
* **Cross-platform consistency**: While originally an Apple concept, we generate and maintain equivalent IDs for all payment providers to ensure consistent subscription tracking

User Attributes [#user-attributes]

Any attributes you set using the [User Attributes API](/docs/sdk/quickstart/setting-user-properties) are automatically included in your webhook payloads. This lets you correlate webhook events with your own user data.

For example, you could identify which user made a purchase or had a subscription expire.

```json
{
  "data": {
    // ... other fields ...
    "userAttributes": {
      "name": "Jane Doe",
      "foo": "baz"
    }
  }
}
```

The attributes included match whatever you've set via `Superwall.shared.setUserAttributes(_:)` (or the equivalent method on Android/Flutter/React Native).

Notes [#notes]

* **Currency handling**:
  * `price` and `proceeds` are always in USD
  * `priceInPurchasedCurrency` is in the currency specified by `currencyCode`
  * `exchangeRate` was used to convert from original currency to USD
* **Family Sharing** (App Store only):
  * When `isFamilyShare` is true with `price > 0`: These are events for the **family organizer** who pays for the subscription (initial\_purchase, renewal, non\_renewing\_purchase)
  * When `isFamilyShare` is true with `price = 0`: These are events for **family members** who use the shared subscription without paying (renewal, uncancellation, billing\_issue, etc.)
* **Refunds**: Negative values in `price`, `proceeds`, or `priceInPurchasedCurrency` indicate refunds
* **Transaction IDs**:
  * `transactionId`: Unique ID for this specific transaction
  * `originalTransactionId`: Subscription ID (first transaction in the subscription group)
* Commission and tax percentages help you understand the revenue breakdown
* **Timestamps**:
  * `timestamp` (root level): When the webhook was created
  * `ts` (in data): When the actual event occurred
  * `purchasedAt`: When the transaction was originally purchased