Chat actions
Paywall cards
Agents send a paywall card from an iOS or Android conversation. The customer taps it and your app opens its purchase screen. Reception records the tap; your app handles the purchase.
Pick the setup by where your paywall is built. What processes the purchase doesn’t matter. The setup prompt picks it for you.
| Your paywall is built in | Setup |
|---|---|
| RevenueCat’s paywall builder (RevenueCatUI) | RevenueCat |
| Superwall’s paywall builder, also with RevenueCat for purchases | Superwall |
| Adapty’s Paywall Builder | Adapty |
| Your app’s own code, with RevenueCat or StoreKit for purchases | Paywall in your code |
Native iOS: add the matching Swift file and call start on the main actor after configuring Reception and your vendor. The files also report where each paywall is built and the products it sells, shown to your team in Settings → your app → Paywalls; that needs Reception 1.4 or later. On an older version, remove the source:, products: and presentable: arguments. React Native/Expo: use the TypeScript files below. Flutter: use the Dart files below. Native Android: register your paywalls in Kotlin as shown in Android. Reception itself has no purchase-provider dependency.
How paywall cards work
Your app reports which paywalls this customer can open. The Send an offer dialog lists only those, as the device last reported them.
RevenueCat and paywalls in your code: a card stays available until the customer owns every product on it, then turns gray with “No longer available”. A customer has one subscription per subscription group, so a card with several plans of one group stays available for switching plans; Apple handles upgrades, downgrades and crossgrades.
Superwall: your campaign audiences decide which customers get a card.
Titles are for your team. The customer sees a button, View offer by default, and your optional message. Admins can add button labels per app in Settings → your app → Offers. The built-in label is translated; custom labels appear as written.
Previously sent paywalls are marked Sent, or Sent · no reply if the customer has not written since the last send. Only an unanswered repeat requires you to type send offer.
A message keeps the title it was sent with.
iOS and Android conversations; React Native and Flutter apps show cards on Android from
react-native-reception1.6.0 andreception_flutter1.2.0, and on iOS with every version. Every offer card sends a push, like a reply. Apple treats those as promotional, so your app's permission flow must ask for consent to offers and provide an opt-out first; see Offer pushes. The push uses the card message, custom button label or translated “See your discount” fallback; notification templates and Show message still apply.Separate from Reception billing and subscription metadata.
RevenueCat
Every RevenueCat offering is listed for your team with its products; the ones with a published paywall and a compatible identifier become cards. New offerings reach support without an app update. Add ReceptionRevenueCatPaywalls.swift below, then start it after Reception.configure and Purchases.configure:
ReceptionRevenueCatPaywalls.start()If your app completes purchases itself (purchasesAreCompletedBy: .myApp), pass its purchase and restore handlers to the PaywallView in the file.
Add an offer without an app update
App Store Connect: create the product. Put it in the same subscription group as your other plans, so subscribers switch instead of double-subscribing. Sandbox works once it is Ready to Submit; production needs Apple’s approval.
RevenueCat: import the product and attach it to your entitlement.
Create an offering with a package for the product. Create a paywall for it and publish it. Drafts and offerings without a paywall are listed for your team but can’t be sent.
Relaunch or foreground the app, then open the conversation. The offer is in Send offer.
Plan changes: send the offering with your plans. Exit offer: create an offering with only the discounted product.
Behavior and limits
Title: the offering’s description. Rename it in RevenueCat if needed.
Refresh: offerings reload at launch and foreground. Changes in RevenueCat can take a moment to appear. Cards update after purchases and restores.
Tap: the offering’s paywall opens above the chat. Closing it, completing a purchase, or a successful restore with an active entitlement returns to the conversation.
Metadata: the file updates the customer’s plan (
subscription_status,plan,renews_onorexpires_on) when RevenueCat reports new customer info. Conversation details show changes after the next device sync. If your app callssetMetadata(_:)itself, see Your own metadata.Accounts: call
Reception.shared.logout()before switching accounts or callingPurchases.logOut. With verified identity,identify(token:)for another user also resets the session; call it beforePurchases.logIn.identify(userId:name:email:)alone does not reset it.Up to 20 offerings, in identifier order. Offering IDs need 1–64 ASCII letters, digits, underscores, dots or hyphens; other offerings are left out.
Products: each offering reports its packages’ product IDs, localized prices, renewal periods and free trials for your team, at most 10 per offering and 40 in all.
The file owns
paywalls,paywallAvailabilityandonPaywall. Don’t set them elsewhere.Requires RevenueCat 5.19 or later with StoreKit 2, its default. Xcode 27 requires RevenueCat 5.78 or later.
import SwiftUI
import UIKit
import Reception
import RevenueCat
import RevenueCatUI
/// Offers every RevenueCat offering with a published paywall as a Reception paywall card.
///
/// Start once on the main actor after `Reception.configure` and `Purchases.configure`:
///
/// ReceptionRevenueCatPaywalls.start()
///
/// Offerings are re-read at launch and foreground, so new RevenueCat paywalls reach support without an
/// app update. A card is unavailable once the customer owns every product in its offering, so
/// subscribers can still switch plans. A tap presents the offering's paywall above the chat. Agents see
/// the offering's description as the card title.
///
/// By default the customer's plan is also sent as support metadata. If your app calls
/// `setMetadata(_:)` itself, pass `syncsSubscriptionMetadata: false` and merge
/// `subscriptionMetadata(for:)` into your own dictionary, because each call replaces all metadata.
@MainActor
enum ReceptionRevenueCatPaywalls {
private static var started = false
private static var syncsMetadata = true
private static var offeringsTask: Task<Void, Never>?
private static var customerInfoTask: Task<Void, Never>?
private static var foregroundObserver: NSObjectProtocol?
static func start(syncsSubscriptionMetadata: Bool = true) {
guard !started else { return }
guard Purchases.isConfigured else {
print("ReceptionRevenueCatPaywalls: call start() after Purchases.configure")
return
}
started = true
syncsMetadata = syncsSubscriptionMetadata
Reception.shared.paywallAvailability = { id in await isAvailable(id) }
Reception.shared.onPaywall = { id in present(id) }
foregroundObserver = NotificationCenter.default.addObserver(
forName: UIApplication.willEnterForegroundNotification, object: nil, queue: .main
) { _ in
MainActor.assumeIsolated { reloadOfferings() }
}
customerInfoTask = Task {
for await customerInfo in Purchases.shared.customerInfoStream {
if syncsMetadata {
Reception.shared.setMetadata(subscriptionMetadata(for: customerInfo))
}
Reception.shared.refreshPaywalls(invalidate: true)
}
}
reloadOfferings()
}
/// The customer's plan for the support dashboard.
static func subscriptionMetadata(for customerInfo: CustomerInfo) -> [String: String] {
let latest = customerInfo.entitlements.active.values.max {
($0.expirationDate ?? .distantFuture) < ($1.expirationDate ?? .distantFuture)
}
guard let entitlement = latest else { return ["subscription_status": "none"] }
var metadata = ["subscription_status": "active", "plan": entitlement.productIdentifier]
if entitlement.isSandbox { metadata["environment"] = "sandbox" }
if let expiration = entitlement.expirationDate {
metadata[entitlement.willRenew ? "renews_on" : "expires_on"] =
expiration.formatted(.iso8601.year().month().day())
}
return metadata
}
private static func reloadOfferings() {
offeringsTask?.cancel()
offeringsTask = Task {
guard let offerings = try? await Purchases.shared.offerings(), !Task.isCancelled else { return }
// Offerings without a published paywall are listed for the team only, never sent as cards.
Reception.shared.paywalls = offerings.all.values
.sorted { $0.identifier < $1.identifier }
.map { ReceptionPaywall(id: $0.identifier, title: title(for: $0), source: .revenueCat, products: products(in: $0), presentable: $0.hasPaywall) }
}
}
/// What the offering sells, shown to your team in Reception.
private static func products(in offering: Offering) -> [ReceptionPaywall.Product] {
offering.availablePackages.map(\.storeProduct).map { product in
let trial = product.introductoryDiscount.flatMap { $0.paymentMode == .freeTrial ? $0.subscriptionPeriod : nil }
return ReceptionPaywall.Product(id: product.productIdentifier, price: product.localizedPriceString,
period: product.subscriptionPeriod.map(isoPeriod), trial: trial.map(isoPeriod))
}
}
private static func isoPeriod(_ period: SubscriptionPeriod) -> String {
switch period.unit {
case .day: "P\(period.value)D"
case .week: "P\(period.value)W"
case .month: "P\(period.value)M"
case .year: "P\(period.value)Y"
}
}
/// Reception accepts titles of up to 60 characters.
private static func title(for offering: Offering) -> String {
let description = offering.serverDescription.trimmingCharacters(in: .whitespacesAndNewlines)
var title = description.isEmpty ? offering.identifier : description
while title.utf16.count > 60 { title.removeLast() }
return title
}
private static func isAvailable(_ id: String) async -> Bool {
guard let offering = try? await Purchases.shared.offerings().all[id], offering.hasPaywall,
let customerInfo = try? await Purchases.shared.customerInfo() else { return false }
let products = offering.availablePackages.map(\.storeProduct)
// Consumables can be bought again, so owning one never exhausts an offering.
let consumables = Set(products.filter { $0.productType == .consumable }.map(\.productIdentifier))
let owned = customerInfo.activeSubscriptions
.union(customerInfo.nonSubscriptions.map(\.productIdentifier))
.subtracting(consumables)
let productIDs = Set(products.map(\.productIdentifier))
return !productIDs.isEmpty && !productIDs.isSubset(of: owned)
}
/// Presents above the support chat, so closing the paywall returns to the conversation.
private static func present(_ id: String) {
Task {
guard let offering = try? await Purchases.shared.offerings().all[id], offering.hasPaywall else {
Reception.shared.refreshPaywalls(invalidate: true)
return
}
let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
let scene = scenes.first { $0.activationState == .foregroundActive } ?? scenes.first
var presenter = scene?.keyWindow?.rootViewController
while let presented = presenter?.presentedViewController, !presented.isBeingDismissed {
presenter = presented
}
guard let presenter else { return }
weak var controller: UIViewController?
let paywall = PaywallView(offering: offering, displayCloseButton: true)
.onRequestedDismissal { controller?.dismiss(animated: true) }
.onPurchaseCompleted { _ in controller?.dismiss(animated: true) }
.onRestoreCompleted { customerInfo in
if !customerInfo.entitlements.active.isEmpty { controller?.dismiss(animated: true) }
}
let hosting = UIHostingController(rootView: paywall)
controller = hosting
presenter.present(hosting, animated: true)
}
}
}Your own metadata
Each setMetadata(_:) call replaces all metadata. If your app sets its own, start with ReceptionRevenueCatPaywalls.start(syncsSubscriptionMetadata: false) and send the plan together with your values whenever the customer info changes:
Task {
for await customerInfo in Purchases.shared.customerInfoStream {
// yourMetadata() is your app's existing dictionary.
let plan = ReceptionRevenueCatPaywalls.subscriptionMetadata(for: customerInfo)
Reception.shared.setMetadata(yourMetadata().merging(plan) { _, plan in plan })
}
}Merge the plan the same way wherever your app already calls setMetadata(_:), for example after login.
With ReceptionStorePaywall or ReceptionSuperwallPaywalls, start it with syncsSubscriptionMetadata: false and merge await subscriptionMetadata() from the same file the same way, at launch, on foreground and after purchases.
Superwall
Your Superwall placements become cards. A card is available when Superwall would show a paywall for its placement to this customer, so audiences, holdouts and entitlements stay in Superwall. A tap registers the placement. Works the same when RevenueCat processes purchases.
Add ReceptionSuperwallPaywalls.swift below, then start it after Reception.configure and Superwall.configure with every placement your app registers. Titles are names your team recognizes, up to 60 characters. Placement IDs need 1–64 ASCII letters, digits, underscores, dots or hyphens:
ReceptionSuperwallPaywalls.start(placements: [
ReceptionPaywall(id: "upgrade_pro", title: "Upgrade to Pro"),
ReceptionPaywall(id: "unlock_export", title: "Unlock export"),
])Behavior and limits
Placements come from your code, because Superwall has no list of them in the app. A new placement needs an app update anyway; new paywalls and campaigns on existing placements reach support without one.
Refresh: cards update once Superwall has loaded and after purchases.
Checking availability can confirm experiment assignments, record holdout or no-match trigger events and use up holdout occurrence limits before a tap. Placement parameters are not passed, so audiences that need them don’t match.
Tap: Superwall presents the paywall above the chat. Closing it returns to the conversation. Superwall shows one paywall at a time, so one still open below the chat, for example after a cancelled purchase, closes first, and the chat with it.
Metadata: the file sends the customer’s plan (
subscription_status,planwith the product ID,renews_onwhile it renews orexpires_onafter cancelling) once Superwall has loaded, after purchases and on foreground. Auto-renewable App Store subscriptions come from StoreKit, whether Superwall or RevenueCat processes purchases. Non-renewing subscriptions need your app’s expiry logic; this helper omits them from plan metadata. Web checkouts sendplanwithout a date; entitlements granted without a purchase send onlysubscription_status. If your app callssetMetadata(_:)itself, see Your own metadata.Up to 20 placements.
Products: Superwall chooses the paywall per audience, so the file reports only the products you list on a placement, with
ReceptionPaywall(id:title:products:).The file owns
paywalls,paywallAvailabilityandonPaywall. Don’t set them elsewhere.Requires SuperwallKit 4.17.0 or later. Upgrade older integrations before adding this helper; earlier versions can fail to build with current Xcode.
import Combine
import Reception
import StoreKit
import SuperwallKit
import UIKit
/// Offers your Superwall placements as Reception paywall cards.
///
/// Start once on the main actor after `Reception.configure` and `Superwall.configure`, with every
/// placement your app registers:
///
/// ReceptionSuperwallPaywalls.start(placements: [
/// ReceptionPaywall(id: "upgrade_pro", title: "Upgrade to Pro"),
/// ])
///
/// A card is available when Superwall would show a paywall for its placement to this customer.
/// A tap registers the placement, so Superwall presents the paywall above the chat. Superwall shows one
/// paywall at a time, so one still open below the chat closes first, together with the chat.
///
/// By default the customer's plan is also sent as support metadata. If your app calls
/// `setMetadata(_:)` itself, pass `syncsSubscriptionMetadata: false` and merge
/// `subscriptionMetadata()` into your own dictionary, because each call replaces all metadata.
@MainActor
enum ReceptionSuperwallPaywalls {
private static var syncsMetadata = true
private static var observer: AnyCancellable?
private static var foregroundObserver: NSObjectProtocol?
static func start(placements: [ReceptionPaywall], syncsSubscriptionMetadata: Bool = true) {
guard observer == nil else { return }
syncsMetadata = syncsSubscriptionMetadata
Reception.shared.paywalls = placements.map { ReceptionPaywall(id: $0.id, title: $0.title, source: .superwall, products: $0.products) }
Reception.shared.paywallAvailability = { id in await isAvailable(id) }
Reception.shared.onPaywall = { id in
Task { @MainActor in
// Like Superwall's own follow-up paywalls: one still open, as after a cancelled purchase, makes way.
// The chat on top of it closes first, since closing the paywall would otherwise close only the chat.
if Superwall.shared.isPaywallPresented {
Reception.shared.closeChat()
for _ in 0..<40 where Reception.shared.isChatOpen { try? await Task.sleep(for: .milliseconds(50)) }
await Superwall.shared.dismiss()
}
let handler = PaywallPresentationHandler()
// StoreKit entitlements can settle after Superwall publishes its purchase state.
handler.onDismiss { _, _ in
Task { @MainActor in await syncMetadata() }
}
Superwall.shared.register(placement: id, handler: handler)
}
}
// Recheck once Superwall has loaded and after purchases or plan changes.
observer = Superwall.shared.$subscriptionStatus
.combineLatest(Superwall.shared.$configurationStatus, Superwall.shared.$customerInfo)
.sink { @Sendable _ in
Task { @MainActor in
Reception.shared.refreshPaywalls(invalidate: true)
await syncMetadata()
}
}
// Cancelling in the App Store changes no purchase, so the plan is also rechecked on foreground.
foregroundObserver = NotificationCenter.default.addObserver(
forName: UIApplication.willEnterForegroundNotification, object: nil, queue: .main
) { _ in
Task { @MainActor in await syncMetadata() }
}
}
/// The customer's plan for the support dashboard.
static func subscriptionMetadata() async -> [String: String] {
// App Store subscriptions, whichever purchase controller bought them; StoreKit knows renewal.
var latest: StoreKit.Transaction?
for await result in StoreKit.Transaction.currentEntitlements {
guard case .verified(let transaction) = result,
transaction.productType == .autoRenewable
else { continue }
if (transaction.expirationDate ?? .distantFuture) > (latest?.expirationDate ?? .distantPast) {
latest = transaction
}
}
if let transaction = latest {
var metadata = ["subscription_status": "active", "plan": transaction.productID]
if transaction.environment != .production { metadata["environment"] = "sandbox" }
if let expiration = transaction.expirationDate {
var willRenew = false
if case .verified(let renewal)? = await transaction.subscriptionStatus?.renewalInfo {
willRenew = renewal.willAutoRenew
}
metadata[willRenew ? "renews_on" : "expires_on"] = expiration.formatted(.iso8601.year().month().day())
}
return metadata
}
// Web checkouts Superwall knows about; their renewal state isn't available here.
let web = Superwall.shared.customerInfo.subscriptions.filter { $0.isActive && !$0.isRevoked }
if let subscription = web.max(by: { ($0.expirationDate ?? .distantFuture) < ($1.expirationDate ?? .distantFuture) }) {
return ["subscription_status": "active", "plan": subscription.productId]
}
// Entitlements granted without a purchase have no plan.
if case .active = Superwall.shared.subscriptionStatus { return ["subscription_status": "active"] }
return ["subscription_status": "none"]
}
private static func syncMetadata() async {
// Before Superwall has loaded, "none" could be wrong.
guard syncsMetadata, Superwall.shared.subscriptionStatus != .unknown else { return }
Reception.shared.setMetadata(await subscriptionMetadata())
}
private static func isAvailable(_ placement: String) async -> Bool {
guard Superwall.shared.configurationStatus == .configured,
Superwall.shared.subscriptionStatus != .unknown else { return false }
if case .paywall = await Superwall.shared.getPresentationResult(forPlacement: placement) { return true }
return false
}
}Adapty
Your Adapty placements become cards. A card is available while the customer lacks an active access level for a product on the placement’s paywall. Placements whose paywall is built in your own code instead of Paywall Builder are listed for your team with their products but can’t be sent.
Add ReceptionAdaptyPaywalls.swift below, then start it after Reception.configure, Adapty.activate and AdaptyUI.activate with every placement your app uses. Placement IDs need 1–64 ASCII letters, digits, underscores, dots or hyphens:
ReceptionAdaptyPaywalls.start(placements: ["premium", "winback"])Behavior and limits
Placements come from your code, because Adapty has no list of them in the app. New paywalls, products and audiences on existing placements reach support without an app update.
Title: the paywall’s name in Adapty. Rename it there if needed.
Refresh: placements, products and the plan reload at launch, on foreground and after a paywall closes.
Tap: the Paywall Builder paywall opens above the chat. Closing it, a purchase or a restore with an active access level returns to the conversation.
Metadata: the file sends the customer’s plan (
subscription_status,planwith the product ID,renews_onwhile it renews orexpires_onafter cancelling) from the profile’s access levels. If your app callssetMetadata(_:)itself, see Your own metadata.Accounts: call
Reception.shared.logout()beforeAdapty.identifyfor another user orAdapty.logout().Up to 20 placements.
The file owns
paywalls,paywallAvailabilityandonPaywall. Don’t set them elsewhere. It never setsAdapty.delegate, so yours keeps working.Requires Adapty 3.0 or later with AdaptyUI.
import Adapty
import AdaptyUI
import Reception
import UIKit
/// Offers your Adapty placements as Reception paywall cards.
///
/// Start once on the main actor after `Reception.configure`, `Adapty.activate` and `AdaptyUI.activate`,
/// with every placement your app uses:
///
/// ReceptionAdaptyPaywalls.start(placements: ["premium", "winback"])
///
/// A card is available while the customer lacks an active access level for a product on the placement's
/// paywall. A tap presents the Paywall Builder paywall above the chat.
///
/// By default the customer's plan is also sent as support metadata. If your app calls
/// `setMetadata(_:)` itself, pass `syncsSubscriptionMetadata: false` and merge
/// `subscriptionMetadata(for:)` into your own dictionary, because each call replaces all metadata.
@MainActor
enum ReceptionAdaptyPaywalls {
private static var placements: [String] = []
private static var syncsMetadata = true
/// What the last reload fetched, so availability checks don't ask Adapty again.
private static var loaded: [String: (paywall: AdaptyPaywall, products: [AdaptyPaywallProduct])] = [:]
private static var profile: AdaptyProfile?
private static var loadTask: Task<Void, Never>?
private static var foregroundObserver: NSObjectProtocol?
private static let paywallDelegate = PaywallDelegate()
static func start(placements: [String], syncsSubscriptionMetadata: Bool = true) {
guard foregroundObserver == nil else { return }
Self.placements = placements
syncsMetadata = syncsSubscriptionMetadata
Reception.shared.paywallAvailability = { id in await isAvailable(id) }
Reception.shared.onPaywall = { id in present(id) }
foregroundObserver = NotificationCenter.default.addObserver(
forName: UIApplication.willEnterForegroundNotification, object: nil, queue: .main
) { _ in
MainActor.assumeIsolated { reload() }
}
reload()
}
/// The customer's plan for the support dashboard.
static func subscriptionMetadata(for profile: AdaptyProfile) -> [String: String] {
let active = profile.accessLevels.values.filter(\.isActive)
guard let level = active.max(by: { ($0.expiresAt ?? .distantFuture) < ($1.expiresAt ?? .distantFuture) }) else {
return ["subscription_status": "none"]
}
var metadata = ["subscription_status": "active", "plan": level.vendorProductId]
if let expiration = level.expiresAt {
metadata[level.willRenew ? "renews_on" : "expires_on"] = expiration.formatted(.iso8601.year().month().day())
}
return metadata
}
/// Reloads the placements, their products and the plan.
fileprivate static func reload() {
loadTask?.cancel()
loadTask = Task {
var fetched: [String: (paywall: AdaptyPaywall, products: [AdaptyPaywallProduct])] = [:]
for placement in placements {
guard let paywall = try? await Adapty.getPaywall(placementId: placement), !Task.isCancelled else { continue }
fetched[placement] = (paywall, (try? await Adapty.getPaywallProducts(paywall: paywall)) ?? [])
}
let profile = try? await Adapty.getProfile()
guard !Task.isCancelled else { return }
loaded = fetched
Self.profile = profile
// A placement whose paywall is built in the app's own code is listed for the team only.
Reception.shared.paywalls = placements.compactMap { placement in
loaded[placement].map { ReceptionPaywall(id: placement, title: title(for: $0.paywall), source: .adapty,
products: $0.products.map(product), presentable: $0.paywall.hasViewConfiguration) }
}
// Setting `paywalls` keeps earlier approvals while checking; invalidating hides a just-bought paywall right away.
Reception.shared.refreshPaywalls(invalidate: true)
if syncsMetadata, let profile {
Reception.shared.setMetadata(subscriptionMetadata(for: profile))
}
}
}
/// Reception accepts titles of up to 60 characters.
private static func title(for paywall: AdaptyPaywall) -> String {
var title = paywall.name.trimmingCharacters(in: .whitespacesAndNewlines)
if title.isEmpty { title = paywall.placement.id }
while title.utf16.count > 60 { title.removeLast() }
return title
}
private static func product(_ product: AdaptyPaywallProduct) -> ReceptionPaywall.Product {
let offer = product.subscriptionOffer
let trial = offer?.paymentMode == .freeTrial ? offer.flatMap { isoPeriod($0.subscriptionPeriod, times: $0.numberOfPeriods) } : nil
return ReceptionPaywall.Product(id: product.vendorProductId, price: product.localizedPrice,
period: product.subscriptionPeriod.flatMap { isoPeriod($0) }, trial: trial)
}
private static func isoPeriod(_ period: AdaptySubscriptionPeriod, times: Int = 1) -> String? {
let units = period.numberOfUnits * max(1, times)
switch period.unit {
case .day: return "P\(units)D"
case .week: return "P\(units)W"
case .month: return "P\(units)M"
case .year: return "P\(units)Y"
case .unknown: return nil
}
}
private static func isAvailable(_ id: String) async -> Bool {
guard let entry = loaded[id], entry.paywall.hasViewConfiguration, !entry.products.isEmpty, let profile else { return false }
// Consumables can be bought again, so owning one never exhausts a placement.
return entry.products.contains { product in
product.adaptyProductType == "consumable" || profile.accessLevels[product.accessLevelId]?.isActive != true
}
}
/// Presents above the support chat, so closing the paywall returns to the conversation.
private static func present(_ id: String) {
Task {
guard let paywall = loaded[id]?.paywall, paywall.hasViewConfiguration,
let configuration = try? await AdaptyUI.getPaywallConfiguration(forPaywall: paywall),
let controller = try? AdaptyUI.paywallController(with: configuration, delegate: paywallDelegate) else {
reload()
return
}
let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
let scene = scenes.first { $0.activationState == .foregroundActive } ?? scenes.first
var presenter = scene?.keyWindow?.rootViewController
while let presented = presenter?.presentedViewController, !presented.isBeingDismissed {
presenter = presented
}
presenter?.present(controller, animated: true)
}
}
/// AdaptyUI closes the paywall itself after a purchase or a close tap; this rechecks cards and the plan afterwards.
@MainActor
private final class PaywallDelegate: NSObject, AdaptyPaywallControllerDelegate {
func paywallControllerDidDisappear(_ controller: AdaptyPaywallController) {
ReceptionAdaptyPaywalls.reload()
}
func paywallController(_ controller: AdaptyPaywallController, didFailPurchase product: AdaptyPaywallProduct, error: AdaptyError) {}
func paywallController(_ controller: AdaptyPaywallController, didFinishRestoreWith profile: AdaptyProfile) {
if profile.accessLevels.values.contains(where: \.isActive) { controller.dismiss(animated: true) }
}
func paywallController(_ controller: AdaptyPaywallController, didFailRestoreWith error: AdaptyError) {}
}
}Paywall in your code
A paywall you built in your app’s code becomes a card, whether it buys through RevenueCat or StoreKit. Add ReceptionStorePaywall.swift below, replace YourPaywallView() with your paywall view, given the same environment objects and dependencies as where your app normally shows it, and productIDs with the products it sells, then start it after Reception.configure. For a UIKit paywall, wrap its view controller in a UIViewControllerRepresentable:
ReceptionStorePaywall.start()Behavior and limits
Availability: the card stays available until the customer owns every product in
productIDs. Consumables never count as owned. Non-renewing subscriptions remain owned after they expire, unless refunded or revoked. Ownership comes from the device’s App Store account, so it includes App Store purchases made through RevenueCat, but not web purchases, granted entitlements or your app’s login.Refresh: closing your paywall and any App Store transaction update, such as an approved Ask to Buy or a refund, recheck the card.
Tap: your paywall opens above the chat. Closing it returns to the conversation and rechecks the card.
Metadata: the file sends the customer’s plan (
subscription_status,planwith the product ID,renews_onwhile it renews orexpires_onafter cancelling) at launch, on foreground and after App Store transaction updates. It reports only auto-renewable subscriptions from the device’s App Store account. Non-renewing subscriptions need your app’s expiry logic; this helper omits them from plan metadata. If your app callssetMetadata(_:)itself, see Your own metadata.More paywalls: add one entry per paywall to
paywalls, and switch on the id inonPaywalland in the availability check, each with its own product IDs.The file owns
paywalls,paywallAvailabilityandonPaywall. Don’t set them elsewhere.
import StoreKit
import SwiftUI
import UIKit
import Reception
/// Offers a paywall built in your app's code as a Reception paywall card.
///
/// Start once on the main actor after `Reception.configure`:
///
/// ReceptionStorePaywall.start()
///
/// The card is available until the customer owns every product the paywall sells. A tap presents the
/// paywall above the chat; closing it returns to the conversation.
///
/// By default the customer's plan is also sent as support metadata. If your app calls
/// `setMetadata(_:)` itself, pass `syncsSubscriptionMetadata: false` and merge
/// `subscriptionMetadata()` into your own dictionary, because each call replaces all metadata.
@MainActor
enum ReceptionStorePaywall {
/// The products your paywall sells.
private static let productIDs: Set<String> = ["pro_monthly", "pro_yearly"]
private static var syncsMetadata = true
private static var updatesTask: Task<Void, Never>?
private static var foregroundObserver: NSObjectProtocol?
static func start(syncsSubscriptionMetadata: Bool = true) {
guard updatesTask == nil else { return }
syncsMetadata = syncsSubscriptionMetadata
Reception.shared.paywalls = [ReceptionPaywall(id: "plans", title: "Plans", source: .app)]
Reception.shared.paywallAvailability = { _ in await isAvailable() }
Reception.shared.onPaywall = { _ in presentAboveChat(YourPaywallView()) }
// Your purchase code still finishes transactions; this only rechecks the card and the plan.
updatesTask = Task {
await reportProducts()
await syncMetadata()
for await _ in Transaction.updates {
Reception.shared.refreshPaywalls(invalidate: true)
await syncMetadata()
}
}
// Cancelling in the App Store creates no transaction, so the plan is also rechecked on foreground.
foregroundObserver = NotificationCenter.default.addObserver(
forName: UIApplication.willEnterForegroundNotification, object: nil, queue: .main
) { _ in
Task { @MainActor in await syncMetadata() }
}
}
/// The customer's plan for the support dashboard.
static func subscriptionMetadata() async -> [String: String] {
var latest: StoreKit.Transaction?
for await result in Transaction.currentEntitlements {
guard case .verified(let transaction) = result,
transaction.productType == .autoRenewable
else { continue }
if (transaction.expirationDate ?? .distantFuture) > (latest?.expirationDate ?? .distantPast) {
latest = transaction
}
}
guard let transaction = latest else { return ["subscription_status": "none"] }
var metadata = ["subscription_status": "active", "plan": transaction.productID]
if transaction.environment != .production { metadata["environment"] = "sandbox" }
if let expiration = transaction.expirationDate {
var willRenew = false
if case .verified(let renewal)? = await transaction.subscriptionStatus?.renewalInfo {
willRenew = renewal.willAutoRenew
}
metadata[willRenew ? "renews_on" : "expires_on"] = expiration.formatted(.iso8601.year().month().day())
}
return metadata
}
private static func syncMetadata() async {
guard syncsMetadata else { return }
Reception.shared.setMetadata(await subscriptionMetadata())
}
/// What the paywall sells, shown to your team in Reception.
private static func reportProducts() async {
guard let products = try? await Product.products(for: productIDs), !products.isEmpty else { return }
Reception.shared.paywalls = [ReceptionPaywall(id: "plans", title: "Plans", source: .app, products: products.map { product in
let offer = product.subscription?.introductoryOffer
return ReceptionPaywall.Product(id: product.id, price: product.displayPrice,
period: product.subscription.map { isoPeriod($0.subscriptionPeriod) },
trial: offer.flatMap { $0.paymentMode == .freeTrial ? isoPeriod($0.period) : nil })
})]
}
private static func isoPeriod(_ period: Product.SubscriptionPeriod) -> String {
switch period.unit {
case .day: "P\(period.value)D"
case .week: "P\(period.value)W"
case .month: "P\(period.value)M"
case .year: "P\(period.value)Y"
@unknown default: "P\(period.value)D"
}
}
private static func isAvailable() async -> Bool {
var owned: Set<String> = []
for await result in Transaction.currentEntitlements {
if case .verified(let transaction) = result { owned.insert(transaction.productID) }
}
return !productIDs.isSubset(of: owned)
}
private static func presentAboveChat(_ paywall: some View) {
let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
let scene = scenes.first { $0.activationState == .foregroundActive } ?? scenes.first
var presenter = scene?.keyWindow?.rootViewController
while let presented = presenter?.presentedViewController, !presented.isBeingDismissed {
presenter = presented
}
// Closing the paywall rechecks availability.
let hosting = UIHostingController(rootView: paywall.onDisappear {
Reception.shared.refreshPaywalls(invalidate: true)
})
presenter?.present(hosting, animated: true)
}
}Your paywall closes itself with @Environment(\.dismiss). A StoreKit SubscriptionStoreView shows a close button with .storeButton(.visible, for: .cancellation).
React Native helpers
Choose one file below by where the paywall is built. These use react-native-purchases / react-native-purchases-ui 10.13 and expo-superwall 1.5, with their public types. Keep your vendor’s existing configuration and purchase handling. The helpers run on iOS and Android; with react-native-reception before 1.6.0 Android shows no cards, and on web Reception calls do nothing. On Android the chat sheet is its own activity, so each helper closes the chat before presenting a paywall there.
Each helper owns paywalls, paywallAvailability and onPaywall. Keep one owner for each, start after Reception and the vendor, and clean up on unmount or before changing accounts. Await Reception’s logout (or verified identify for the new account), then the vendor’s login/logout, then restart the helper for that account. This keeps old asynchronous results from writing the next customer’s details. With Superwall, unmount the hook during account changes and remount after its identify/reset completes. Re-register on JavaScript reload.
RevenueCat builder in React Native
Add ReceptionRevenueCatPaywalls.ts, then call ReceptionRevenueCatPaywalls.start() once; save its returned cleanup function. Titles, IDs and products come from offerings; every offering is listed for your team, and the marked ones become cards. The card remains available until all its products are owned; consumables remain available. Foreground refreshes offerings and customer info; purchases/restores refresh availability and metadata. RevenueCatUI.presentPaywall presents above chat on iOS, and after the chat closes on Android, and returns after dismissal.
One React Native difference: PurchasesOffering does not expose Swift’s hasPaywall. In RevenueCat, set offering metadata reception: true only on offerings with a published paywall, and remove it if the paywall is unpublished. The helper sends only flagged offerings as cards and lists the rest for your team; it cannot detect a draft itself. Adding a marked offering still needs no app update. See RevenueCat’s presentation API.
import { AppState, Platform } from 'react-native';
import Purchases, {
PRODUCT_TYPE,
type CustomerInfo,
type PurchasesOffering,
} from 'react-native-purchases';
import RevenueCatUI from 'react-native-purchases-ui';
import { Reception, type ReceptionPaywallProduct } from 'react-native-reception';
function subscriptionMetadata(info: CustomerInfo): Record<string, string> {
const latest = Object.values(info.entitlements.active).sort((a, b) =>
(b.expirationDate ?? '9999').localeCompare(a.expirationDate ?? '9999'),
)[0];
if (!latest) return { subscription_status: 'none' };
const metadata: Record<string, string> = {
subscription_status: 'active',
plan: latest.productIdentifier,
};
if (latest.isSandbox) metadata.environment = 'sandbox';
if (latest.expirationDate)
metadata[latest.willRenew ? 'renews_on' : 'expires_on'] =
latest.expirationDate.slice(0, 10);
return metadata;
}
function title(offering: PurchasesOffering): string {
let result = '';
for (const character of offering.serverDescription.trim() ||
offering.identifier) {
if (result.length + character.length > 60) break;
result += character;
}
return result;
}
/** What the offering sells, shown to your team in Reception. Periods are already ISO 8601, like "P1M". */
function products(offering: PurchasesOffering): ReceptionPaywallProduct[] {
return offering.availablePackages.map(({ product }) => ({
id: product.identifier,
price: product.priceString,
period: product.subscriptionPeriod ?? undefined,
trial:
product.introPrice && product.introPrice.price === 0
? product.introPrice.period
: undefined,
}));
}
function start({ syncsSubscriptionMetadata = true } = {}) {
let active = true;
let revision = 0;
let offeringRevision = 0;
let offerings: Record<string, PurchasesOffering> = {};
const failed = () =>
console.warn('Reception paywalls could not refresh or present.');
const update = (info: CustomerInfo) => {
if (!active) return;
// Invalidate an older foreground request before accepting this snapshot.
revision += 1;
if (syncsSubscriptionMetadata)
void Reception.setMetadata(subscriptionMetadata(info)).catch(failed);
void Reception.refreshPaywalls({ invalidate: true }).catch(failed);
};
const reload = async () => {
const request = ++offeringRevision;
const metadataRevision = revision;
const [all, info] = await Promise.all([
Purchases.getOfferings(),
Purchases.getCustomerInfo(),
]);
if (!active || request !== offeringRevision) return;
const entries = Object.values(all.all)
.filter((offering) => /^[A-Za-z0-9_.-]{1,64}$/.test(offering.identifier))
.sort((a, b) => a.identifier.localeCompare(b.identifier))
.slice(0, 20);
// RN does not expose Swift's hasPaywall. Mark published offerings in dashboard metadata;
// the others are listed for the team but never sent as cards.
offerings = Object.fromEntries(
entries
.filter((offering) => offering.metadata.reception === true)
.map((offering) => [offering.identifier, offering]),
);
Reception.paywalls = entries.map((offering) => ({
id: offering.identifier,
title: title(offering),
source: 'revenuecat',
products: products(offering),
presentable: offering.metadata.reception === true,
}));
if (metadataRevision === revision) update(info);
else void Reception.refreshPaywalls({ invalidate: true }).catch(failed);
};
Reception.paywallAvailability = async (id) => {
const offering = offerings[id];
if (!offering) return false;
const info = await Purchases.getCustomerInfo();
const owned = new Set([
...info.activeSubscriptions,
...info.nonSubscriptionTransactions.map((item) => item.productIdentifier),
]);
return (
active &&
offering.availablePackages.some(
({ product }) =>
product.productType === PRODUCT_TYPE.CONSUMABLE ||
!owned.has(product.identifier),
)
);
};
Reception.onPaywall = (id) => {
const offering = offerings[id];
if (!active || !offering) return;
// On Android the chat sheet is its own activity; close it so the paywall shows above.
void (Platform.OS === 'android' ? Reception.closeChat() : Promise.resolve())
.then(() => RevenueCatUI.presentPaywall({ offering, displayCloseButton: true }))
.then(() => {
if (active) return reload();
})
.catch(failed);
};
Purchases.addCustomerInfoUpdateListener(update);
const foreground = AppState.addEventListener('change', (state) => {
if (state === 'active') void reload().catch(failed);
});
void reload().catch(failed);
return () => {
active = false;
Purchases.removeCustomerInfoUpdateListener(update);
foreground.remove();
Reception.onPaywall = null;
Reception.paywallAvailability = null;
Reception.paywalls = [];
};
}
export const ReceptionRevenueCatPaywalls = { start, subscriptionMetadata };If your app completes purchases itself, pass your existing purchaseLogic to presentPaywall, as in RevenueCat’s integration. Keep a close button in the paywall design.
Superwall in React Native
Use Superwall’s expo-superwall SDK (also supports bare React Native with Expo Modules). Add ReceptionSuperwallPaywalls.ts. Inside one component below your existing SuperwallProvider, after Reception is configured, call useReceptionSuperwallPaywalls(placements). Define placements once outside the component, with every placement the app registers, for example [{ id: 'upgrade_pro', title: 'Upgrade to Pro' }].
This is a hook because Superwall exposes loaded subscription state through React. It preserves Superwall’s campaign audiences and purchase controller, including RevenueCat. Availability checks can record holdout/no-match events just like the Swift helper. Subscription metadata comes from Superwall customer info; unknown status is not reported as an unsubscribed customer. Dates are sent only when the provider supplies them; granted access without a subscription has no product ID.
import { useEffect, useRef } from 'react';
import { AppState, Platform } from 'react-native';
import {
useSuperwall,
useSuperwallEvents,
type CustomerInfo,
type SubscriptionStatus,
} from 'expo-superwall';
import { Reception, type ReceptionPaywall } from 'react-native-reception';
// Resolves once the chat has closed, or after two seconds if it stays open.
function chatClosed() {
return new Promise<void>((resolve) => {
if (!Reception.isChatOpen) return resolve();
const done = () => {
clearTimeout(timer);
subscription.remove();
resolve();
};
const timer = setTimeout(done, 2000);
const subscription = Reception.addChatOpenListener((open) => {
if (!open) done();
});
});
}
export function subscriptionMetadata(
info: CustomerInfo,
status: SubscriptionStatus,
): Record<string, string> {
const latest = info.subscriptions
.filter((item) => item.isActive && !item.isRevoked)
.sort((a, b) =>
(b.expirationDate ?? '9999').localeCompare(a.expirationDate ?? '9999'),
)[0];
if (!latest)
return {
subscription_status: status.status === 'ACTIVE' ? 'active' : 'none',
};
const metadata: Record<string, string> = {
subscription_status: 'active',
plan: latest.productId,
};
if (latest.expirationDate)
metadata[latest.willRenew ? 'renews_on' : 'expires_on'] =
latest.expirationDate.slice(0, 10);
return metadata;
}
// Mount once below your existing SuperwallProvider, after Reception is configured.
export function useReceptionSuperwallPaywalls(
placements: readonly ReceptionPaywall[],
{ syncsSubscriptionMetadata = true } = {},
) {
const {
isConfigured,
subscriptionStatus,
customerInfo,
getCustomerInfo,
getPresentationResult,
registerPlacement,
dismiss,
} = useSuperwall();
// Superwall shows one paywall at a time, so the hook remembers whether one is open.
const presented = useRef(false);
useSuperwallEvents({
onPaywallPresent: () => {
presented.current = true;
},
onPaywallDismiss: () => {
presented.current = false;
},
});
useEffect(() => {
if (
(Platform.OS !== 'ios' && Platform.OS !== 'android') ||
!isConfigured ||
subscriptionStatus.status === 'UNKNOWN'
)
return;
let active = true;
const failed = () =>
console.warn('Reception paywalls could not refresh or present.');
Reception.paywallAvailability = async (id) => {
const result = await getPresentationResult(id);
// The native bridge returns a serialized result, not a JavaScript class instance.
return active && 'type' in result && result.type === 'Paywall';
};
Reception.onPaywall = (id) => {
void (async () => {
// Like Superwall's own follow-up paywalls: one still open, as after a cancelled purchase, makes way. The
// chat on top of it closes first, since closing the paywall would otherwise close only the chat. On Android
// the chat sheet is its own activity, so it always closes first to let the paywall show above.
const reopen = presented.current;
if (reopen || Platform.OS === 'android') {
await Reception.closeChat();
await chatClosed();
}
if (reopen) await dismiss();
await registerPlacement(id);
})().catch(failed);
};
Reception.paywalls = placements.map((placement) => ({
...placement,
source: 'superwall',
}));
if (syncsSubscriptionMetadata && customerInfo) {
void Reception.setMetadata(
subscriptionMetadata(customerInfo, subscriptionStatus),
).catch(failed);
}
void Reception.refreshPaywalls({ invalidate: true }).catch(failed);
return () => {
active = false;
Reception.onPaywall = null;
Reception.paywallAvailability = null;
Reception.paywalls = [];
};
}, [
isConfigured,
subscriptionStatus,
customerInfo,
getPresentationResult,
registerPlacement,
dismiss,
placements,
syncsSubscriptionMetadata,
]);
useEffect(() => {
if ((Platform.OS !== 'ios' && Platform.OS !== 'android') || !isConfigured)
return;
const refresh = () => {
void getCustomerInfo().catch(() =>
console.warn('Reception subscription refresh failed.'),
);
};
refresh();
const foreground = AppState.addEventListener('change', (state) => {
if (state === 'active') refresh();
});
return () => foreground.remove();
}, [isConfigured, getCustomerInfo]);
}Your own React Native paywall
Add ReceptionStorePaywall.ts and replace products with the screen’s product IDs and consumable flags. After configuration, call ReceptionStorePaywall.start(presentYourPaywall), passing your existing function that presents the paywall and resolves when dismissed. Keep its returned cleanup function. On iOS, use a native modal presentation, not navigation hidden underneath Reception’s sheet, and verify that closing the paywall returns to chat. On Android every React Native screen appears under the sheet, so the helper closes the chat first there.
This version uses your existing RevenueCat customer info, including active subscriptions and non-subscription purchases. If the app uses another store library, use its current entitlements, subscription dates and purchase-change listener in these same places; do not add RevenueCat just for Reception. It does not implement a purchase flow. Unlike the native Swift StoreKit helper, account-level or web entitlements known to RevenueCat can contribute to metadata.
import { AppState, Platform } from 'react-native';
import Purchases, { type CustomerInfo } from 'react-native-purchases';
import { Reception } from 'react-native-reception';
function subscriptionMetadata(info: CustomerInfo): Record<string, string> {
const latest = Object.values(info.entitlements.active).sort((a, b) =>
(b.expirationDate ?? '9999').localeCompare(a.expirationDate ?? '9999'),
)[0];
if (!latest) return { subscription_status: 'none' };
const metadata: Record<string, string> = {
subscription_status: 'active',
plan: latest.productIdentifier,
};
if (latest.isSandbox) metadata.environment = 'sandbox';
if (latest.expirationDate)
metadata[latest.willRenew ? 'renews_on' : 'expires_on'] =
latest.expirationDate.slice(0, 10);
return metadata;
}
// Replace with the products sold by your existing screen. Consumables never count as owned.
const products = [
{ id: 'pro_monthly', consumable: false },
{ id: 'pro_yearly', consumable: false },
];
function start(
present: () => Promise<void>,
{ syncsSubscriptionMetadata = true } = {},
) {
let active = true;
let revision = 0;
const failed = () =>
console.warn('Reception paywall could not refresh or present.');
const update = (info: CustomerInfo) => {
if (!active) return;
revision += 1;
if (syncsSubscriptionMetadata)
void Reception.setMetadata(subscriptionMetadata(info)).catch(failed);
void Reception.refreshPaywalls({ invalidate: true }).catch(failed);
};
const reload = async () => {
const request = ++revision;
const info = await Purchases.getCustomerInfo();
if (active && request === revision) update(info);
};
Reception.paywallAvailability = async (id) => {
if (id !== 'plans') return false;
const info = await Purchases.getCustomerInfo();
const owned = new Set([
...info.activeSubscriptions,
...info.nonSubscriptionTransactions.map((item) => item.productIdentifier),
]);
return (
active &&
products.some((product) => product.consumable || !owned.has(product.id))
);
};
Reception.onPaywall = (id) => {
if (active && id === 'plans')
// On Android the chat sheet is its own activity; close it so the paywall shows above.
void (Platform.OS === 'android' ? Reception.closeChat() : Promise.resolve())
.then(present)
.then(() => {
if (active) return reload();
})
.catch(failed);
};
Reception.paywalls = [
{
id: 'plans',
title: 'Plans',
source: 'app',
products: products.map(({ id }) => ({ id })),
},
];
Purchases.addCustomerInfoUpdateListener(update);
const foreground = AppState.addEventListener('change', (state) => {
if (state === 'active') void reload().catch(failed);
});
void reload().catch(failed);
return () => {
active = false;
Purchases.removeCustomerInfoUpdateListener(update);
foreground.remove();
Reception.onPaywall = null;
Reception.paywallAvailability = null;
Reception.paywalls = [];
};
}
export const ReceptionStorePaywall = { start, subscriptionMetadata };Your own React Native metadata
Each setMetadata replaces all fields. If the developer chose other fields too, or declined subscription fields, disable automatic metadata: RevenueCat start({ syncsSubscriptionMetadata: false }); your own screen start(presentYourPaywall, { syncsSubscriptionMetadata: false }); Superwall useReceptionSuperwallPaywalls(placements, { syncsSubscriptionMetadata: false }).
When subscription fields are wanted, merge the selected file’s subscriptionMetadata result into your app’s single metadata owner on launch, foreground and customer-info changes. RevenueCat and custom helpers accept CustomerInfo; Superwall accepts its customer info and subscription status. Do not send "none" before Superwall’s status is loaded.
// Inside your existing RevenueCat customer-info handler; yourMetadata() is your app's dictionary.
await Reception.setMetadata({
...yourMetadata(),
...ReceptionRevenueCatPaywalls.subscriptionMetadata(customerInfo),
});Verify cards on a device using the same dashboard steps below. Type checking confirms API compatibility, not your vendor’s campaign, products, purchase or native presentation configuration.
Flutter helpers
Choose one file below by where the paywall is built. These use purchases_flutter / purchases_ui_flutter 10.19, superwallkit_flutter 2.6 and, in one wiring example, in_app_purchase 3.3, with their public types, and reception_flutter 1.0 or later, which bundles Reception 1.7. Keep your vendor’s existing configuration and purchase handling. The helpers run on iOS and Android; with reception_flutter before 1.2.0 Android shows no cards, and on web and desktop each helper’s start does nothing. On Android the chat sheet is its own activity, so each helper closes the chat before presenting a paywall there.
Each helper owns paywalls, paywallAvailability and onPaywall. Keep one owner for each and one owner of setMetadata. Call start in main() after await Reception.configure(...) and your vendor’s configuration, and keep the returned helper. Its stop() cancels its listeners and clears those three properties while the helper still owns them. Before changing accounts, call stop(), await Reception’s logout (or verified identify for the new account), then the vendor’s login/logout, then start the helper again for that account. This keeps old asynchronous results from writing the next customer’s details. Hot restart clears Dart callbacks while the native chat keeps its state; main() runs again, and so does start.
// Switching accounts with RevenueCat; paywalls is the helper start() returned.
paywalls.stop();
await Reception.logout();
await Purchases.logIn(newUserId);
paywalls = ReceptionRevenueCatPaywalls.start();RevenueCat builder in Flutter
Add lib/reception_revenuecat_paywalls.dart, then call ReceptionRevenueCatPaywalls.start() once in main() after Purchases.configure. Titles, IDs and products come from offerings; every offering is listed for your team, and the marked ones become cards. The card remains available until all its products are owned. RevenueCat’s Flutter products don’t say which ones are consumable, so pass yours as start(consumableProductIds: {'coins_100'}); they never count as owned. Returning to the app refreshes offerings and customer info; purchases and restores refresh availability and metadata. RevenueCatUI.presentPaywall presents above the chat on iOS, and after the chat closes on Android, and returns after dismissal.
One Flutter difference: like React Native, Flutter’s Offering does not expose Swift’s hasPaywall. In RevenueCat, set offering metadata reception: true only on offerings with a published paywall, and remove it if the paywall is unpublished. The helper sends only flagged offerings as cards and lists the rest for your team; it cannot detect a draft itself. Adding a marked offering still needs no app update. See RevenueCat’s presentation API.
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:purchases_flutter/purchases_flutter.dart';
import 'package:purchases_ui_flutter/purchases_ui_flutter.dart';
import 'package:reception_flutter/reception_flutter.dart';
/// Offers RevenueCat offerings as Reception paywall cards.
///
/// Start once in `main()` after `Reception.configure` and
/// `Purchases.configure`, and keep the returned helper:
///
/// var paywalls = ReceptionRevenueCatPaywalls.start();
///
/// Every offering is listed for your team; those with offering metadata
/// `reception: true` become cards. Call [stop] before changing accounts, then
/// start again for the new account.
final class ReceptionRevenueCatPaywalls {
ReceptionRevenueCatPaywalls._(this._syncsMetadata, this._consumables);
final bool _syncsMetadata;
final Set<String> _consumables;
AppLifecycleListener? _lifecycle;
var _active = false;
var _revision = 0;
var _offeringRevision = 0;
var _offerings = <String, Offering>{};
static final _validId = RegExp(r'^[A-Za-z0-9_.-]{1,64}$');
/// Starts on iOS and Android; on web and desktop Reception shows no cards,
/// so this does nothing there. RevenueCat's Flutter products don't say which ones are
/// consumable, so list those in [consumableProductIds]; they never count as
/// owned.
static ReceptionRevenueCatPaywalls start({
bool syncsSubscriptionMetadata = true,
Set<String> consumableProductIds = const {},
}) {
final helper = ReceptionRevenueCatPaywalls._(
syncsSubscriptionMetadata,
consumableProductIds,
);
if (!_supported) return helper;
helper._active = true;
Reception.paywallAvailability = helper._isAvailable;
Reception.onPaywall = helper._present;
Purchases.addCustomerInfoUpdateListener(helper._update);
helper._lifecycle = AppLifecycleListener(onResume: helper._refresh);
helper._refresh();
return helper;
}
/// The customer's plan for the support dashboard.
static Map<String, String> subscriptionMetadata(CustomerInfo info) {
final active = info.entitlements.active.values.toList()
..sort(
(a, b) =>
(b.expirationDate ?? '9999').compareTo(a.expirationDate ?? '9999'),
);
if (active.isEmpty) return {'subscription_status': 'none'};
final latest = active.first;
return {
'subscription_status': 'active',
'plan': latest.productIdentifier,
if (latest.isSandbox) 'environment': 'sandbox',
if (latest.expirationDate case final date?)
latest.willRenew ? 'renews_on' : 'expires_on': date.split('T').first,
};
}
/// Removes the listeners, and the cards while this helper still owns them.
void stop() {
if (!_active) return;
_active = false;
Purchases.removeCustomerInfoUpdateListener(_update);
_lifecycle?.dispose();
if (Reception.paywallAvailability == _isAvailable) {
Reception.paywallAvailability = null;
}
if (Reception.onPaywall == _present) {
Reception.onPaywall = null;
Reception.paywalls = const [];
}
}
void _update(CustomerInfo info) {
if (!_active) return;
// Invalidates an older foreground request before accepting this snapshot.
_revision += 1;
if (_syncsMetadata) {
unawaited(
Reception.setMetadata(subscriptionMetadata(info)).catchError(_failed),
);
}
unawaited(Reception.refreshPaywalls(invalidate: true).catchError(_failed));
}
void _refresh() => unawaited(_reload().catchError(_failed));
Future<void> _reload() async {
final request = ++_offeringRevision;
final metadataRevision = _revision;
final (all, info) = await (
Purchases.getOfferings(),
Purchases.getCustomerInfo(),
).wait;
if (!_active || request != _offeringRevision) return;
final entries =
all.all.values
.where((offering) => _validId.hasMatch(offering.identifier))
.toList()
..sort((a, b) => a.identifier.compareTo(b.identifier));
final listed = entries.take(20).toList();
// Flutter's Offering has no hasPaywall. Offerings marked in RevenueCat
// become cards; the others are listed for the team but never sent.
_offerings = {
for (final offering in listed)
if (_marked(offering)) offering.identifier: offering,
};
Reception.paywalls = [
for (final offering in listed)
ReceptionPaywall(
id: offering.identifier,
title: _title(offering),
source: 'revenuecat',
products: _products(offering),
presentable: _marked(offering),
),
];
if (metadataRevision == _revision) {
_update(info);
} else {
await Reception.refreshPaywalls(invalidate: true);
}
}
static bool _marked(Offering offering) =>
offering.metadata['reception'] == true;
/// Reception accepts titles of up to 60 characters.
static String _title(Offering offering) {
final description = offering.serverDescription.trim();
final title = StringBuffer();
for (final rune
in (description.isEmpty ? offering.identifier : description).runes) {
final character = String.fromCharCode(rune);
if (title.length + character.length > 60) break;
title.write(character);
}
return title.toString();
}
/// What the offering sells, shown to your team in Reception. Periods are
/// already ISO 8601, like "P1M".
static List<ReceptionPaywallProduct> _products(Offering offering) => [
for (final package in offering.availablePackages)
ReceptionPaywallProduct(
id: package.storeProduct.identifier,
price: package.storeProduct.priceString,
period: package.storeProduct.subscriptionPeriod,
trial: switch (package.storeProduct.introductoryPrice) {
final intro? when intro.price == 0 => intro.period,
_ => null,
},
),
];
Future<bool> _isAvailable(String id) async {
final offering = _offerings[id];
if (offering == null) return false;
final info = await Purchases.getCustomerInfo();
final owned = {
...info.activeSubscriptions,
for (final transaction in info.nonSubscriptionTransactions)
transaction.productIdentifier,
};
return _active &&
offering.availablePackages.any((package) {
final product = package.storeProduct.identifier;
return _consumables.contains(product) || !owned.contains(product);
});
}
/// RevenueCatUI presents natively and returns after dismissal: above the
/// chat on iOS, and after the chat closes on Android, where the chat sheet
/// is its own activity.
void _present(String id) {
final offering = _offerings[id];
if (!_active || offering == null) return;
unawaited(_presentPaywall(offering).catchError(_failed));
}
Future<void> _presentPaywall(Offering offering) async {
if (defaultTargetPlatform == TargetPlatform.android) {
await Reception.closeChat();
if (!_active) return;
}
await RevenueCatUI.presentPaywall(
offering: offering,
displayCloseButton: true,
);
if (_active) await _reload();
}
/// Reception runs on iOS and Android, not on web or desktop.
static bool get _supported =>
!kIsWeb &&
(defaultTargetPlatform == TargetPlatform.iOS ||
defaultTargetPlatform == TargetPlatform.android);
static void _failed(Object error) =>
debugPrint('Reception paywalls could not refresh or present: $error');
}In Flutter, presentPaywall takes no purchase logic. If your app completes purchases itself (PurchasesAreCompletedByMyApp), show RevenueCat’s PaywallView with your existing purchaseLogic through your own Flutter paywall instead. Keep a close button in the paywall design.
Superwall in Flutter
Use Superwall’s superwallkit_flutter SDK. Add lib/reception_superwall_paywalls.dart, then call ReceptionSuperwallPaywalls.start(placements: placements) once in main() after Superwall.configure, with every placement the app registers, for example [ReceptionPaywall(id: 'upgrade_pro', title: 'Upgrade to Pro')]. Superwall chooses the paywall per audience, so the file reports only the products you list on a placement with products:.
It preserves Superwall’s campaign audiences and purchase controller, including RevenueCat. Availability checks can record holdout/no-match events just like the Swift helper. Cards update once Superwall has loaded and after purchases or plan changes; returning to the app rechecks the plan. A tap registers the placement, and Superwall presents the paywall above the chat on iOS, and after the chat closes on Android; one still open below the chat closes first, together with the chat. Subscription metadata comes from Superwall customer info; unknown status is not reported as an unsubscribed customer. Dates are sent only when the provider supplies them; granted access without a subscription has no product ID. Around Superwall.shared.identify and Superwall.shared.reset(), stop the helper first and start it again once they complete.
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:reception_flutter/reception_flutter.dart';
import 'package:superwallkit_flutter/superwallkit_flutter.dart';
/// Offers your Superwall placements as Reception paywall cards.
///
/// Start once in `main()` after `Reception.configure` and
/// `Superwall.configure`, with every placement your app registers, and keep the
/// returned helper:
///
/// var paywalls = ReceptionSuperwallPaywalls.start(placements: [
/// ReceptionPaywall(id: 'upgrade_pro', title: 'Upgrade to Pro'),
/// ]);
///
/// A card is available when Superwall would show a paywall for its placement to
/// this customer. A tap registers the placement, so Superwall presents the
/// paywall above the chat. Call [stop] before changing accounts, then start
/// again for the new account.
final class ReceptionSuperwallPaywalls {
ReceptionSuperwallPaywalls._(this._syncsMetadata);
final bool _syncsMetadata;
final _subscriptions = <StreamSubscription<Object?>>[];
AppLifecycleListener? _lifecycle;
var _active = false;
var _revision = 0;
/// Starts on iOS and Android; on web and desktop Reception shows no cards,
/// so this does nothing there.
static ReceptionSuperwallPaywalls start({
required List<ReceptionPaywall> placements,
bool syncsSubscriptionMetadata = true,
}) {
final helper = ReceptionSuperwallPaywalls._(syncsSubscriptionMetadata);
if (!_supported) return helper;
helper._active = true;
Reception.paywalls = [
for (final placement in placements)
ReceptionPaywall(
id: placement.id,
title: placement.title,
source: 'superwall',
products: placement.products,
),
];
Reception.paywallAvailability = helper._isAvailable;
Reception.onPaywall = helper._present;
// Recheck after purchases or plan changes.
helper._subscriptions
..add(
Superwall.shared.subscriptionStatus.listen(
(_) => helper._changed(),
onError: _failed,
),
)
..add(
Superwall.shared.customerInfoStream.listen(
(_) => helper._changed(),
onError: _failed,
),
);
// Cancelling in the App Store changes no purchase, so the plan is also
// rechecked when the app returns to the foreground.
helper._lifecycle = AppLifecycleListener(
onResume: helper._syncMetadataLater,
);
unawaited(helper._whenConfigured().catchError(_failed));
return helper;
}
/// The customer's plan for the support dashboard. Don't send it while
/// [status] is still unknown.
static Map<String, String> subscriptionMetadata(
CustomerInfo info,
SubscriptionStatus status,
) {
final never = DateTime.utc(9999);
final active =
info.subscriptions
.where((item) => item.isActive && !item.isRevoked)
.toList()
..sort(
(a, b) => (b.expirationDate ?? never).compareTo(
a.expirationDate ?? never,
),
);
if (active.isEmpty) {
return {'subscription_status': status.isActive ? 'active' : 'none'};
}
final latest = active.first;
return {
'subscription_status': 'active',
'plan': latest.productId,
if (latest.expirationDate case final date?)
latest.willRenew ? 'renews_on' : 'expires_on': date
.toUtc()
.toIso8601String()
.substring(0, 10),
};
}
/// Removes the listeners, and the cards while this helper still owns them.
void stop() {
if (!_active) return;
_active = false;
for (final subscription in _subscriptions) {
unawaited(subscription.cancel());
}
_lifecycle?.dispose();
if (Reception.paywallAvailability == _isAvailable) {
Reception.paywallAvailability = null;
}
if (Reception.onPaywall == _present) {
Reception.onPaywall = null;
Reception.paywalls = const [];
}
}
/// Superwall's Flutter SDK has no configuration stream, so the helper checks
/// until it has loaded, then rechecks the cards and the plan.
Future<void> _whenConfigured() async {
for (var attempt = 0; attempt < 120 && _active; attempt++) {
if (await Superwall.shared.getConfigurationStatus() ==
ConfigurationStatus.configured) {
_changed();
return;
}
await Future<void>.delayed(const Duration(milliseconds: 500));
}
}
void _changed() {
if (!_active) return;
unawaited(Reception.refreshPaywalls(invalidate: true).catchError(_failed));
_syncMetadataLater();
}
void _syncMetadataLater() => unawaited(_syncMetadata().catchError(_failed));
Future<void> _syncMetadata() async {
if (!_syncsMetadata) return;
final request = ++_revision;
final (status, info) = await (
Superwall.shared.getSubscriptionStatus(),
Superwall.shared.getCustomerInfo(),
).wait;
// Before Superwall has loaded, "none" could be wrong.
if (!_active ||
request != _revision ||
status is SubscriptionStatusUnknown) {
return;
}
await Reception.setMetadata(subscriptionMetadata(info, status));
}
Future<bool> _isAvailable(String placement) async {
final (configuration, status) = await (
Superwall.shared.getConfigurationStatus(),
Superwall.shared.getSubscriptionStatus(),
).wait;
if (configuration != ConfigurationStatus.configured ||
status is SubscriptionStatusUnknown) {
return false;
}
final result = await Superwall.shared.getPresentationResult(placement);
return _active && result is PaywallPresentationResult;
}
void _present(String placement) {
if (_active) unawaited(_register(placement).catchError(_failed));
}
Future<void> _register(String placement) async {
// Like Superwall's own follow-up paywalls: one still open, as after a
// cancelled purchase, makes way. The chat on top of it closes first, since
// closing the paywall would otherwise close only the chat. On Android the
// chat sheet is its own activity, so it always closes first to let the
// paywall show above.
final presented = await Superwall.shared.getIsPaywallPresented();
if (presented || defaultTargetPlatform == TargetPlatform.android) {
if (!_active) return;
await Reception.closeChat();
await _chatClosed();
}
if (presented) {
if (!_active) return;
await Superwall.shared.dismiss();
// dismiss() returns before the paywall has closed.
for (var attempt = 0; attempt < 40; attempt++) {
if (!await Superwall.shared.getIsPaywallPresented()) break;
await Future<void>.delayed(const Duration(milliseconds: 50));
}
}
if (!_active) return;
// StoreKit entitlements can settle after Superwall publishes its purchase
// state, so the plan is rechecked when the paywall closes.
final handler = PaywallPresentationHandler()
..onDismiss((info, result) => _syncMetadataLater());
await Superwall.shared.registerPlacement(placement, handler: handler);
}
/// Completes once the chat has closed, or after two seconds if it stays open.
static Future<void> _chatClosed() {
final open = Reception.isChatOpen;
if (!open.value) return Future.value();
final closed = Completer<void>();
void listener() {
if (!open.value && !closed.isCompleted) closed.complete();
}
open.addListener(listener);
return closed.future
.timeout(const Duration(seconds: 2), onTimeout: () {})
.whenComplete(() => open.removeListener(listener));
}
/// Reception runs on iOS and Android, not on web or desktop.
static bool get _supported =>
!kIsWeb &&
(defaultTargetPlatform == TargetPlatform.iOS ||
defaultTargetPlatform == TargetPlatform.android);
static void _failed(Object error) =>
debugPrint('Reception paywalls could not refresh or present: $error');
}Your own Flutter paywall
Add lib/reception_store_paywall.dart and replace _products with the screen’s product IDs and consumable flags. Copy it unchanged otherwise; it works with any store library, or none. After configuration, call ReceptionStorePaywall.start(presentYourPaywall, ownedProductIds: …), passing your existing function that shows the screen and completes when it closes, and keep the returned helper. A Flutter screen renders below Reception’s native chat sheet, so the helper closes the sheet first and opens it again when your screen closes. If the chat is a ReceptionChatView inside your own route, pass closesChatSheet: false instead. Verify that closing the paywall returns to chat.
var paywall = ReceptionStorePaywall.start(
() => navigatorKey.currentState!.push(
MaterialPageRoute<void>(
fullscreenDialog: true,
builder: (_) => const YourPaywallScreen(),
),
),
ownedProductIds: yourOwnedProductIds, // See the wiring below.
);Wire three things to the store library your app already uses; never add one just for Reception. The file does not implement a purchase flow.
ownedProductIdsreturns the product IDs the customer owns now. The card stays available while any listed product is not owned.purchasesChanged(optional) emits after purchases, restores and expiries, so the card and metadata update right away. Returning to the app and closing your screen also update them.subscriptionMetadata(optional) returns the plan fields for the dashboard. Leave it out when your app sends its own metadata.
Without a store library, pass ownedProductIds: () async => <String>{}: every product stays available, and the app sends the chosen user details from its own fields.
With purchases_flutter, owned products and the plan come from RevenueCat customer info, including active subscriptions, non-subscription purchases and account-level or web entitlements:
// In main(), after Purchases.configure and Reception.configure:
final purchasesChanged = StreamController<void>.broadcast();
Purchases.addCustomerInfoUpdateListener((_) => purchasesChanged.add(null));
var paywall = ReceptionStorePaywall.start(
presentYourPaywall,
ownedProductIds: () async {
final info = await Purchases.getCustomerInfo();
return {
...info.activeSubscriptions,
for (final transaction in info.nonSubscriptionTransactions)
transaction.productIdentifier,
};
},
purchasesChanged: purchasesChanged.stream,
subscriptionMetadata: () async =>
revenueCatMetadata(await Purchases.getCustomerInfo()),
);/// The customer's plan for the support dashboard.
Map<String, String> revenueCatMetadata(CustomerInfo info) {
final active = info.entitlements.active.values.toList()
..sort(
(a, b) =>
(b.expirationDate ?? '9999').compareTo(a.expirationDate ?? '9999'),
);
if (active.isEmpty) return {'subscription_status': 'none'};
final latest = active.first;
return {
'subscription_status': 'active',
'plan': latest.productIdentifier,
if (latest.isSandbox) 'environment': 'sandbox',
if (latest.expirationDate case final date?)
latest.willRenew ? 'renews_on' : 'expires_on': date.split('T').first,
};
}With in_app_purchase, note completed and restored purchases from the purchase stream. Your existing listener keeps completing purchases. in_app_purchase doesn’t report expiry; if your server verifies receipts, return its active product IDs instead:
// In main(), after Reception.configure. Your existing purchase listener keeps
// completing purchases; this one only notes what the customer owns.
final owned = <String>{};
final purchasesChanged = InAppPurchase.instance.purchaseStream.map((purchases) {
for (final purchase in purchases) {
if (purchase.status == PurchaseStatus.purchased ||
purchase.status == PurchaseStatus.restored) {
owned.add(purchase.productID);
}
}
});
var paywall = ReceptionStorePaywall.start(
presentYourPaywall,
ownedProductIds: () async => owned,
purchasesChanged: purchasesChanged,
);import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:reception_flutter/reception_flutter.dart';
/// Offers a paywall screen built in your Flutter code as a Reception paywall
/// card. It works with any store library: you supply the owned product IDs.
///
/// Start once in `main()` after `Reception.configure` and your store
/// library's setup, passing your function that shows the screen and completes
/// when it closes, and keep the returned helper:
///
/// var paywall = ReceptionStorePaywall.start(
/// presentYourPaywall,
/// ownedProductIds: yourOwnedProductIds,
/// );
///
/// The card stays available until the customer owns every product in
/// `_products`. Call [stop] before changing accounts, then start again for the
/// new account.
final class ReceptionStorePaywall {
ReceptionStorePaywall._(
this._present,
this._ownedProductIds,
this._subscriptionMetadata,
this._closesChat,
);
// Replace with the products sold by your existing screen. Consumables never
// count as owned.
static const _products = [
(id: 'pro_monthly', consumable: false),
(id: 'pro_yearly', consumable: false),
];
final Future<void> Function() _present;
final Future<Set<String>> Function() _ownedProductIds;
final FutureOr<Map<String, String>> Function()? _subscriptionMetadata;
final bool _closesChat;
StreamSubscription<void>? _purchases;
AppLifecycleListener? _lifecycle;
var _active = false;
var _revision = 0;
/// Starts on iOS and Android; on web and desktop Reception shows no cards,
/// so this does nothing there.
///
/// [ownedProductIds] returns the product IDs the customer currently owns.
/// [purchasesChanged] emits after purchases, restores and expiries.
/// [subscriptionMetadata] returns the plan fields for the support dashboard;
/// leave it out when your app sends its own metadata. Pass
/// `closesChatSheet: false` when the chat is a `ReceptionChatView` inside
/// your own route.
static ReceptionStorePaywall start(
Future<void> Function() present, {
required Future<Set<String>> Function() ownedProductIds,
Stream<void>? purchasesChanged,
FutureOr<Map<String, String>> Function()? subscriptionMetadata,
bool closesChatSheet = true,
}) {
final helper = ReceptionStorePaywall._(
present,
ownedProductIds,
subscriptionMetadata,
closesChatSheet,
);
if (!_supported) return helper;
helper._active = true;
Reception.paywallAvailability = helper._isAvailable;
Reception.onPaywall = helper._open;
Reception.paywalls = [
ReceptionPaywall(
id: 'plans',
title: 'Plans',
source: 'app',
products: [
for (final product in _products)
ReceptionPaywallProduct(id: product.id),
],
),
];
helper._purchases = purchasesChanged?.listen((_) => helper._changed());
helper._lifecycle = AppLifecycleListener(onResume: helper._changed);
helper._changed();
return helper;
}
/// Removes the listeners, and the card while this helper still owns it.
void stop() {
if (!_active) return;
_active = false;
unawaited(_purchases?.cancel());
_lifecycle?.dispose();
if (Reception.paywallAvailability == _isAvailable) {
Reception.paywallAvailability = null;
}
if (Reception.onPaywall == _open) {
Reception.onPaywall = null;
Reception.paywalls = const [];
}
}
void _changed() {
if (!_active) return;
unawaited(Reception.refreshPaywalls(invalidate: true).catchError(_failed));
unawaited(_syncMetadata().catchError(_failed));
}
Future<void> _syncMetadata() async {
final metadata = _subscriptionMetadata;
if (metadata == null) return;
final request = ++_revision;
final fields = await metadata();
if (_active && request == _revision) await Reception.setMetadata(fields);
}
Future<bool> _isAvailable(String id) async {
if (id != 'plans') return false;
final owned = await _ownedProductIds();
return _active &&
_products.any(
(product) => product.consumable || !owned.contains(product.id),
);
}
void _open(String id) {
if (!_active || id != 'plans') return;
unawaited(_presentPaywall().catchError(_failed));
}
/// Your screen renders in Flutter, below Reception's native chat sheet, so
/// the sheet closes first and opens again when your screen closes.
Future<void> _presentPaywall() async {
final reopen = _closesChat && Reception.isChatOpen.value;
if (reopen) {
await Reception.closeChat();
await _chatClosed();
if (!_active) return;
}
await _present();
if (!_active) return;
if (reopen) await Reception.openChat();
_changed();
}
/// Completes once the chat has closed, or after two seconds if it stays open.
static Future<void> _chatClosed() {
final open = Reception.isChatOpen;
if (!open.value) return Future.value();
final closed = Completer<void>();
void listener() {
if (!open.value && !closed.isCompleted) closed.complete();
}
open.addListener(listener);
return closed.future
.timeout(const Duration(seconds: 2), onTimeout: () {})
.whenComplete(() => open.removeListener(listener));
}
/// Reception runs on iOS and Android, not on web or desktop.
static bool get _supported =>
!kIsWeb &&
(defaultTargetPlatform == TargetPlatform.iOS ||
defaultTargetPlatform == TargetPlatform.android);
static void _failed(Object error) =>
debugPrint('Reception paywall could not refresh or present: $error');
}Your own Flutter metadata
Each setMetadata replaces all fields. If the developer chose other fields too, or declined subscription fields, disable automatic metadata: RevenueCat ReceptionRevenueCatPaywalls.start(syncsSubscriptionMetadata: false); your own screen: leave out subscriptionMetadata; Superwall ReceptionSuperwallPaywalls.start(placements: placements, syncsSubscriptionMetadata: false).
When subscription fields are wanted, merge the selected file’s subscriptionMetadata result into your app’s single metadata owner on launch, foreground and customer-info changes. The RevenueCat helper accepts CustomerInfo (with your own screen, use your revenueCatMetadata function from the wiring above); Superwall accepts its customer info and subscription status. Do not send “none” while Superwall’s status is SubscriptionStatusUnknown.
// Inside your existing RevenueCat customer-info listener; yourMetadata() is your app's map.
await Reception.setMetadata({
...yourMetadata(),
...ReceptionRevenueCatPaywalls.subscriptionMetadata(customerInfo),
});Verify cards on a device using the same dashboard steps below. Static analysis confirms API compatibility, not your vendor’s campaign, products, purchase or native presentation configuration.
Android
The Android SDK has the same paywall API in Kotlin, without ready-made vendor files yet: register the paywalls your app can show and present them from onPaywall. Do this after Reception.configure, for example in Application.onCreate:
import sh.reception.Reception
import sh.reception.ReceptionPaywall
Reception.paywalls = listOf(
ReceptionPaywall(
id = "pro_monthly",
title = "Pro monthly",
source = ReceptionPaywall.Source.App,
products = listOf(ReceptionPaywall.Product("pro.monthly", price = "€4.99", period = "P1M", trial = "P1W")),
),
)
Reception.onPaywall = { id ->
// The chat is its own activity: close it so your paywall shows above, not under, it.
Reception.closeChat()
showYourPaywall(id)
}
// Optional: hide paywalls the person can't buy any more.
Reception.paywallAvailability = { id -> canShowYourPaywall(id) }showYourPaywall and canShowYourPaywall stand for your app’s code; canShowYourPaywall is a suspend function, so it can ask your billing library or vendor. Use ReceptionPaywall.Source.RevenueCat, Superwall or Adapty for a paywall built there; source, products and presentable describe the paywall to your team, as in Swift. The limits are the Swift ones: at most 20 paywalls with unique IDs of 1–64 letters, digits, _, . or -, and titles up to 60 characters.
Above the chat.
onPaywallruns while the chat’s sheet is still up. The sheet is its own activity, so a paywall your app shows as a dialog, bottom sheet or another activity would open under it. CallReception.closeChat()first, as above. iOS keeps the chat open underneath; Android cannot. An embeddedReceptionChatViewis part of your screen and needs nocloseChat().After a purchase. When your billing library or vendor reports a purchase or a restored subscription, call
Reception.refreshPaywalls(invalidate = true), so no paywall stays available until the check finishes. The SDK also checks at launch, on foreground, when the chat opens and after logout.Plan details. To show the plan next to each chat, send it yourself with
setMetadata, for examplesubscription_status,planandrenews_on, as in Your own metadata.Closed without buying. Report
Reception.track("paywall_declined", opensChat = true)when your paywall closes without a purchase; see Automations.
Your app owns its products, disclosures and purchase flow on Google Play too; follow Google Play’s Payments policy.
Send and measure
Open the customer’s latest iOS or Android conversation in Inbox. Select Reopen if it is closed, then Send offer, the lock beside Ask for review.
In Send an offer, choose a Paywall and Button label. Admins manage the app’s offer labels in Settings → your app → Offers.
Leave Message (optional) empty to send only the button, or select Suggested for text in the customer’s reported app language. Edit up to 1,000 characters; cards cannot include images. Check the live Preview.
If the paywall is marked Sent · no reply, type send offer to confirm the repeat. Select Send offer to send the card and close the dialog.
When the customer taps an available card, the SDK calls
onPaywall, emits.paywallOpened, and reports the tap for the dashboard’s “User opened the {paywallTitle} paywall” receipt.
User details list each card’s sender, sent time and open time. Activity at the bottom of Settings → your app → Offers show sent, opened and open rate for 7, 30 or 90 days, per agent. Repeated taps count once. An open is a tap, not a purchase; check your vendor for purchases.
Apple notes
Your app owns its products, disclosures and purchase flow. Follow the App Review Guidelines for payments. Every offer sends a push. Apple requires explicit consent in your app’s UI for promotional notifications and an in-app way to opt out, so ask for that consent in your permission flow before you send offers; see guideline 4.5.4.
Verify on a device
Open support in the app, then send yourself a card from the dashboard. The Send an offer dialog lists your offerings, placements or paywalls.
Tap the card’s button: the paywall opens above the chat. Close it: you are back in the conversation.
Buy with a Sandbox account. With RevenueCat or a paywall in your code, a card turns gray with “No longer available” once you own all its products. Conversation details show your
planandrenews_on; after cancelling in Settings and returning to the app,expires_on.Select the same paywall again: it is marked Sent · no reply and requires confirmation until you send another customer message.
Troubleshooting
| Symptom | Check |
|---|---|
| No paywalls are available for this user | The customer may own everything on them, or the app hasn’t synced yet. Open support in the app and try again. |
| A RevenueCat offering is missing | Publish its paywall; drafts don’t count. React Native and Flutter: set offering metadata reception: true on each offering with a published paywall; without it no card opens. Relaunch or foreground the app; offerings may be cached. |
| An offering shows No paywall and no AI switch | RevenueCat reports no published paywall for it (hasPaywall is false), so there is nothing for a card to open. Attach a paywall to the offering in RevenueCat and publish it, then relaunch the app and open support once; no app update is needed. In React Native and Flutter, add the offering metadata reception: true instead. If the app shows its own paywall screen, use the file for your app’s code. In Adapty, the placement needs a Paywall Builder design. |
| A Superwall placement is missing | Add it to start(placements:). It needs a campaign that shows this customer a paywall; holdouts and non-matching audiences get none. |
| A subscriber can’t be sent a paywall | With RevenueCat or a paywall in your code, they own every product on it; send one with a plan they can switch to. With Superwall, check the campaign audience. |
| Tapping a card opens nothing | Check that start runs at launch and that nothing else sets onPaywall. |
| The RevenueCat paywall has no close button | Add one in the RevenueCat paywall design. Swiping down also closes it. |
| A card shows “No longer available” | The customer bought it, or the app no longer reports it. |
| A paywall is marked Sent | It was sent to this device before. If it says Sent · no reply, type send offer to confirm the repeat. Reset support keeps the send history. |
| The requested id is no longer available | Refresh the conversation and pick from the current list. |
| No open receipt appears | Tap the card in the app and check connectivity. Dashboard previews record nothing. |
| No notification arrives | Check push eligibility: credentials, a registered token and notification permission on the device. |