Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ To toggle its open state, you can use a tap behavior on a button or another elem

<Frame>![](/images/paywall-editor-drawer-toggle.png)</Frame>

You can also bind a drawer's open state to another paywall variable. For example, use `state.didAbandonTransaction` to open a recovery offer drawer after the user cancels the App Store or Google Play purchase sheet. See [Abandoned Transaction Paywalls](/dashboard/guides/tips-abandoned-transaction-paywall) for the full setup.

<Note>
The variable's name is derived by the node's unique identifier. You don't need to set or generally be aware of this value.
</Note>
Expand All @@ -44,4 +46,4 @@ By default, drawers have a minimum height set. This is a general size that works

<Frame>![](/images/paywall-editor-drawer-height-stack.png)</Frame>
</Tab>
</Tabs>
</Tabs>
Original file line number Diff line number Diff line change
Expand Up @@ -140,14 +140,32 @@ The values above apply to any referenced product. There is the notion of a **pri

For example, to reference the price of the selected product (i.e. one the user has clicked or tapped on within the paywall) — you could write `The selected product cost {{ products.selected.price }}`.

There are also two more stock variables which deal with products, but aren't a part of a single product variable itself. They are referenced via the `products` variable:
There are also stock variables that deal with products, but aren't part of a single product variable itself. They are referenced via the `products` variable:

| Property | Type | Example |
| ---------------------- | ------ | ---------- |
| Has Introductory Offer | Bool | True/False |
| Selected Index | Number | 0 |

Use `products.hasIntroductoryOffer` to detect whether or not a user has a trial available. Further, `products.selectedIndex` represents the index of a selected products (i.e. primary would equal 0).
Use `products.hasIntroductoryOffer` to detect whether or not a user has a trial available. Further, `products.selectedIndex` represents the index of a selected product (i.e. primary would equal 0).

Superwall also exposes `products.abandoned` after a user cancels the store purchase sheet, and `products.purchased` after a transaction completes. Once those states are set, these variables point at the product the user abandoned or purchased, so you can use the same fields shown above:

| Property | Type | Example |
| -------- | ---- | ------- |
| Abandoned Product Price | Text | `{{ products.abandoned.price }}` |
| Abandoned Product Period | Text | `{{ products.abandoned.periodly }}` |
| Purchased Product Price | Text | `{{ products.purchased.price }}` |
| Purchased Product Period | Text | `{{ products.purchased.periodly }}` |

</Tab>
<Tab title="State">
| Property | Type | Example |
| -------- | ---- | ------- |
| Did Abandon Transaction | Bool | `{{ state.didAbandonTransaction }}` |
| Did Complete Transaction | Bool | `{{ state.didCompleteTransaction }}` |

Use `state.didAbandonTransaction` to react when a user opens the App Store or Google Play purchase sheet and then cancels before purchase. A common pattern is to bind a drawer's open state to this variable so a recovery offer appears inside the same paywall. See [Abandoned Transaction Paywalls](/dashboard/guides/tips-abandoned-transaction-paywall) for a full example.

</Tab>
<Tab title="User">
Expand Down Expand Up @@ -325,7 +343,9 @@ Here are some common examples:
<Frame>![](/images/docsScrollIntroOffer.gif)</Frame>

<br />
2. **Testing a particular page in a paging paywall:** In this design, there are three distinct pages:
2. **Testing abandoned transaction states:** To test a drawer or offer that appears after a canceled purchase, change `state.didAbandonTransaction` to `true`. If your copy references the abandoned product, choose a product by setting `products.abandonedProductId` to its product reference, such as `primary` or `secondary`.

3. **Testing a particular page in a paging paywall:** In this design, there are three distinct pages:
<br />
<Frame>![](/images/togglePages1.png)</Frame>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ Organization API keys use the same access model:

| Setting | What it controls |
| --- | --- |
| Scopes | Which resources the key can read or write, such as paywalls, campaigns, products, webhooks, charts, users, assets, or access controls. |
| Scopes | Which resources the key can read or write, such as paywalls, campaigns, products, webhooks, charts, users, assets, access controls, or ClickHouse analytics data. |
| Project Access | Whether the key can operate across all projects or only selected projects. |

Both checks must pass. For example, an API key with `paywalls:write` and **Restricted** access to one project can only update paywalls in that project.
Expand All @@ -107,6 +107,8 @@ Use **Settings > API Keys** to review each key's scopes, project access, creatio
Prefer restricted API keys for automation. Give each service only the scopes and projects it needs.
</Tip>

Keys with `data:read` can use the [ClickHouse query API](/dashboard/guides/query-clickhouse) to run read-only SQL against your organization's analytics data.

### Troubleshooting

If a member cannot see a project, confirm that their project access mode is **All Projects** or that the project is selected in their restricted assignments.
Expand Down
167 changes: 167 additions & 0 deletions content/docs/dashboard/guides/query-clickhouse.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
---
title: "Query ClickHouse"
description: "Use the Superwall API to query your organization's ClickHouse-backed analytics data."
---

The ClickHouse query API gives you direct SQL access to the same analytics data Superwall uses for charts and campaign results. Use it when you need flexible reporting, internal dashboards, or ad hoc analysis without maintaining a separate data warehouse.

Requests are scoped to your organization and require an organization API key with the `data:read` scope. Superwall provisions a read-only ClickHouse user for your organization on first use, then applies row-level policies so queries only return data for your organization's applications.

<Warning>
Treat `data:read` keys as sensitive. They can query analytics data for your organization, so create dedicated keys, store them in a secret manager, and revoke them when they are no longer needed.
</Warning>

## Endpoint

Use either `POST` or `GET`:

| Method | Path | SQL location |
| --- | --- | --- |
| `POST` | `https://api.superwall.com/v2/organizations/{organization_id}/query` | Request body |
| `GET` | `https://api.superwall.com/v2/organizations/{organization_id}/query` | `query` URL parameter |

`POST` is recommended for most queries because SQL can be long and multiline.

## Authentication

1. Open **Settings > API Keys** in the Superwall dashboard.
2. Create an organization API key.
3. Give the key the `data:read` scope.
4. Copy the token when Superwall shows it.

Pass the token as a bearer token:

```bash
Authorization: Bearer YOUR_SECRET_TOKEN
```

The token must belong to the organization in the path. A key from another organization cannot query this endpoint.

## Send a query

Set your organization ID and API key:

```bash
export SUPERWALL_ORG_ID="123"
export SUPERWALL_API_KEY="sk_..."
```

Run a query with `POST`:

```bash
curl "https://api.superwall.com/v2/organizations/$SUPERWALL_ORG_ID/query" \
--request POST \
--header "Authorization: Bearer $SUPERWALL_API_KEY" \
--data-binary "SELECT count() FROM sw.events_rep"
```

Run a query with `GET`:

```bash
curl --get "https://api.superwall.com/v2/organizations/$SUPERWALL_ORG_ID/query" \
--header "Authorization: Bearer $SUPERWALL_API_KEY" \
--data-urlencode "query=SELECT count() FROM sw.events_rep"
```

The response is the raw ClickHouse HTTP response. If you do not specify a format, ClickHouse returns its default text format. Add a `FORMAT` clause when you need JSON:

```bash
curl "https://api.superwall.com/v2/organizations/$SUPERWALL_ORG_ID/query" \
--request POST \
--header "Authorization: Bearer $SUPERWALL_API_KEY" \
--data-binary "
SELECT
name,
count() AS events
FROM sw.events_rep
WHERE ts >= now() - INTERVAL 7 DAY
GROUP BY name
ORDER BY events DESC
LIMIT 20
FORMAT JSONEachRow
"
```

## Use ClickHouse HTTP options

The endpoint proxies ClickHouse HTTP requests after Superwall authenticates your organization API key. You can pass standard ClickHouse URL parameters, such as `query`, `database`, or `default_format`, through the query string:

```bash
curl --get "https://api.superwall.com/v2/organizations/$SUPERWALL_ORG_ID/query" \
--header "Authorization: Bearer $SUPERWALL_API_KEY" \
--data-urlencode "query=SELECT name, count() FROM events_rep GROUP BY name LIMIT 20" \
--data-urlencode "database=sw" \
--data-urlencode "default_format=JSONEachRow"
```

Superwall does not expose the generated ClickHouse username and password. Authenticate to the Superwall endpoint with your bearer token instead of connecting directly to the ClickHouse cluster.

## Available tables

Your read-only user can query the analytics tables Superwall exposes for customer reporting:

| Table | Use it for |
| --- | --- |
| `sw.events_rep` | Raw Superwall events, including event name, metadata, properties, sandbox flag, application ID, and timestamp. |
| `sw.events_hr_agg` | Hourly event aggregates. |
| `sw.demand_score_events_rep` | Demand Score event data. |
| `open_revenue.attributed_events_by_ts_rep` | Revenue and attribution events ordered by event time. |
| `open_revenue.paywall_open_events_agg` | Aggregated paywall open events. |
| `sw.subscription_status_rep` | Subscription status records. |
| `sw.user_attributes_rep` | User attributes set through the SDK or paywall flows. |
| `sw.applications_rep` | Application metadata available in ClickHouse. |

Use ClickHouse introspection queries to inspect columns before writing a production query:

```sql
SHOW TABLES FROM sw;
SHOW TABLES FROM open_revenue;
DESCRIBE TABLE sw.events_rep;
DESCRIBE TABLE open_revenue.attributed_events_by_ts_rep;
```

## Query JSON properties

Some event details are stored in JSON strings such as `props` and `meta`. Use ClickHouse JSON functions to extract them:

```sql
SELECT
JSONExtractString(props, '$placement_name') AS placement,
count() AS opens
FROM sw.events_rep
WHERE name = 'paywall_open'
AND ts >= now() - INTERVAL 30 DAY
GROUP BY placement
ORDER BY opens DESC
LIMIT 20
FORMAT JSONEachRow;
```

## Limits

Queries run as a read-only organization user with ClickHouse settings applied:

| Limit | Value |
| --- | --- |
| Maximum execution time | 300 seconds |
| Maximum threads | 4 |
| Maximum memory | 8 GB |
| Maximum bytes read | 20 GB |

If a query times out or uses too much memory, narrow the date range, add filters on `applicationId`, `isSandbox`, or event `name`, and avoid selecting large JSON columns unless you need them.

## Troubleshooting

| Status | What to check |
| --- | --- |
| `401` | The request is missing a bearer token, or the token is invalid or revoked. |
| `403` | The API key does not include the `data:read` scope. |
| `404` | The requested organization resource could not be found. |
| `429` | Too many requests were sent in a short period. Retry later. |
| `500` | ClickHouse returned an unexpected error or Superwall could not proxy the request. Check the SQL and try a smaller query. |

## Related

- [Access Controls](/dashboard/dashboard-settings/overview-settings-access-controls)
- [Charts](/dashboard/charts)
- [Superwall Skill](/dashboard/guides/superwall-skill)
Original file line number Diff line number Diff line change
@@ -1,14 +1,65 @@
---
title: "Abandoned Transaction Paywalls"
description: "Learn how to present a a paywall when a user starts to convert, but then cancels the transaction."
description: "Learn how to respond when a user starts a purchase, then cancels the transaction."
---

### What
Transaction abandon discounts can boost revenue by offering discounts to users who start, but don't complete, in-app purchases. We've seen 25-40% of revenue come from this method in a few of our own apps, and it can be implemented in Superwall without an app update.
When a user opens the store purchase sheet and dismisses it before completing the purchase, Superwall tracks a `transaction_abandon` event. You can respond to that in two ways:

### Why
Somewhere around only 50% of users complete in-app purchases once they start. Offering discounts to those who showed interest, but hesitated, can convert them into paying customers.
1. Show another paywall with a `transaction_abandon` placement.
2. Keep the user on the current paywall and reveal a drawer, offer, or survey using the `didAbandonTransaction` paywall state.

### How
## Show another paywall instead

<iframe width="560" height="315" src="https://www.youtube.com/embed/-LeBeSHTs4g?si=DH7sWlyF-ppoO8tp" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen" referrerpolicy="strict-origin-when-cross-origin"></iframe>
You can add `transaction_abandon` as a placement in a campaign. If a matching paywall is available, Superwall closes the current paywall and presents the new one.

Use this approach when the recovery experience should be a completely separate paywall, such as a dedicated discount page, a transaction-abandon survey template, or a later campaign with its own audience filters.

For campaign setup details and available audience filter parameters, see [`transaction_abandon`](/dashboard/dashboard-campaigns/campaigns-standard-placements#transaction_abandon).

<iframe width="560" height="315" src="https://www.youtube.com/embed/-LeBeSHTs4g?si=DH7sWlyF-ppoO8tp" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen" referrerpolicy="strict-origin-when-cross-origin"></iframe>

## Use `didAbandonTransaction` in the current paywall

Use the `didAbandonTransaction` state when you want the recovery offer to feel like part of the same paywall instead of closing one paywall and opening another.

`didAbandonTransaction` is a boolean state variable that Superwall manages for you. It starts as `false` when the paywall opens or when a new purchase begins. If the user cancels the store purchase sheet, Superwall sets it to `true`.

You can use that state to open a drawer after the abandoned transaction:

<Steps>
<Step title="Add a drawer for the recovery offer">
In the paywall editor, add a [Drawer](/dashboard/dashboard-creating-paywalls/paywall-editor-drawer-component) element. Put the follow-up offer, survey, or personalized message inside the drawer.
</Step>
<Step title="Bind the drawer to the transaction state">
Select the drawer and set its open state to use a dynamic value. Use the `state.didAbandonTransaction` variable as the condition so the drawer opens when the value is `true`.
</Step>
<Step title="Add the follow-up purchase action">
Add a button inside the drawer that starts the purchase you want to offer next. For example, you might show the same product with clearer copy, a discounted product, or a lower-priced alternative.
</Step>
<Step title="Publish and test the paywall">
Preview the paywall on a device, tap the purchase button, then dismiss the App Store or Google Play purchase sheet. The drawer should appear on the same paywall after the transaction is abandoned.
</Step>
</Steps>

<Tip>
If you need to edit or preview the drawer in the paywall editor, open the **Variables** panel and
temporarily set `state.didAbandonTransaction` to `true`.
</Tip>

## Personalize the recovery offer

When a transaction is abandoned, Superwall also stores the abandoned product reference. This lets you personalize copy based on the product the user tried to buy.

For example, if the user attempted to purchase the annual product, you can use the abandoned product variables to show annual-specific copy or pricing inside the drawer:

```liquid
Still interested in {{ products.abandoned.periodly }} access?
```

You can use the same product fields available for your other product variables, such as `products.abandoned.price`, `products.abandoned.periodly`, or `products.abandoned.trialPeriodText`.

<Note>
`products.abandoned.*` refers to the product on the current paywall that the user attempted to
purchase. Campaign audience filters use a separate `abandoned_product_id` value, which is the
store product identifier.
</Note>
1 change: 1 addition & 0 deletions content/docs/dashboard/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"manage-account",

"---Guides---",
"guides/query-clickhouse",
"guides/superwall-skill",
"guides/superwall-mcp",
"guides/migrating-from-revenuecat-to-superwall",
Expand Down
37 changes: 34 additions & 3 deletions content/docs/expo/guides/using-revenuecat.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ The easiest way to integrate RevenueCat with Superwall is using the `CustomPurch
```tsx
import { useEffect } from "react"
import { Platform } from "react-native"
import Purchases, { PURCHASES_ERROR_CODE } from "react-native-purchases"
import Purchases, {
PRODUCT_CATEGORY,
PURCHASES_ERROR_CODE,
} from "react-native-purchases"
import {
CustomPurchaseControllerProvider,
SuperwallProvider,
Expand Down Expand Up @@ -55,13 +58,38 @@ function App() {
controller={{
onPurchase: async (params) => {
try {
const products = await Purchases.getProducts([params.productId])
const product = products[0]
const products = await Promise.all([
Purchases.getProducts([params.productId], PRODUCT_CATEGORY.SUBSCRIPTION),
Purchases.getProducts([params.productId], PRODUCT_CATEGORY.NON_SUBSCRIPTION),
]).then((results) => results.flat())

const product =
products.find((product) => product.identifier === params.productId) ??
(params.platform === "android"
? products.find(
(product) => product.identifier === `${params.productId}:${params.basePlanId}`
)
: undefined) ??
products[0]

if (!product) {
return { type: "failed", error: "Product not found" }
}

if (params.platform === "android" && product.subscriptionOptions?.length) {
const optionId = params.offerId
? `${params.basePlanId}:${params.offerId}`
: params.basePlanId
const option = product.subscriptionOptions.find((option) => option.id === optionId)

if (!option) {
return { type: "failed", error: "Subscription option not found" }
}

await Purchases.purchaseSubscriptionOption(option)
return
}

await Purchases.purchaseStoreProduct(product)
} catch (error: any) {
if (error.code === PURCHASES_ERROR_CODE.PURCHASE_CANCELLED_ERROR) {
Expand Down Expand Up @@ -90,8 +118,11 @@ function App() {
</SuperwallProvider>
</CustomPurchaseControllerProvider>
)
}
```

On Android, `onPurchase` includes a `basePlanId` and may include an `offerId`. Use those values to find the matching RevenueCat subscription option and call `Purchases.purchaseSubscriptionOption(option)`. Calling `Purchases.purchaseStoreProduct(product)` for a Google Play subscription lets RevenueCat choose the product's default offer, which may not be the offer selected on the Superwall paywall.

### 2. Sync Subscription Status

Listen for RevenueCat subscription changes and update Superwall:
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added content/docs/images/adaptive_pricing_toggle.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added content/docs/images/stripe-settings-payments.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading