<!-- AI agents: this is the Markdown version of a Reception documentation page. Index: /llms.txt -->

# Paywall cards

Agents send a paywall card from an iOS 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](#revenuecat) |
| Superwall’s paywall builder, also with RevenueCat for purchases | [Superwall](#superwall) |
| Your app’s own code, with RevenueCat or StoreKit for purchases | [Paywall in your code](#paywall-in-your-code) |

Each setup is one file you add to your app target unchanged, plus one `start` call on the main actor after `Reception.configure` and your vendor’s `configure`. ReceptionKit gains no 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. Owners can add button labels per app in **Settings → Review & Offer CTAs**. 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 conversations only. Offer pushes are off by default. An Owner can turn on **Send a push notification** per app in **Settings → Review & Offer CTAs**. [Offer pushes](/docs/push-notifications#offer-pushes) use the card message, custom button label or translated “See your discount” fallback; notification templates and **Show message** still apply.
- Separate from [Reception billing](/docs/billing) and [subscription metadata](/docs/accounts#metadata).

![Paywall card with the View offer button](/docs/images/ios-paywall-available.webp "An available card opens your paywall.")

![The same card, grayed out with No longer available](/docs/images/ios-paywall-unavailable.webp "A card turns gray when your app no longer makes it available.")

## RevenueCat

Every RevenueCat offering with a published paywall and a compatible identifier becomes a card. New offerings reach support without an app update. Add `ReceptionRevenueCatPaywalls.swift` below, then start it after `Reception.configure` and `Purchases.configure`:

```swift
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

1. 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.
2. RevenueCat: import the product and attach it to your entitlement.
3. Create an offering with a package for the product. Create a paywall for it and **publish** it. Drafts and offerings without a paywall don’t appear.
4. 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_on` or `expires_on`) when RevenueCat reports new customer info. Conversation details show changes after the next device sync. If your app calls `setMetadata(_:)` itself, see [Your own metadata](#your-own-metadata).
- Accounts: call `Reception.shared.logout()` before switching accounts or calling `Purchases.logOut`. With verified identity, `identify(token:)` for another user also resets the session; call it before `Purchases.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.
- The file owns `paywalls`, `paywallAvailability` and `onPaywall`. Don’t set them elsewhere.
- Requires RevenueCat 5.19 or later with StoreKit 2, its default. Xcode 27 requires RevenueCat 5.78 or later.

```swift
import SwiftUI
import UIKit
import ReceptionKit
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 }
            Reception.shared.paywalls = offerings.all.values
                .filter(\.hasPaywall)
                .sorted { $0.identifier < $1.identifier }
                .map { ReceptionPaywall(id: $0.identifier, title: title(for: $0)) }
        }
    }

    /// 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:

```swift
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.

## 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:

```swift
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.
- Up to 20 placements.
- The file owns `paywalls`, `paywallAvailability` and `onPaywall`. Don’t set them elsewhere.
- Requires SuperwallKit 4 or later.

```swift
import Combine
import ReceptionKit
import SuperwallKit

/// 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.
@MainActor
enum ReceptionSuperwallPaywalls {
    private static var observer: AnyCancellable?

    static func start(placements: [ReceptionPaywall]) {
        guard observer == nil else { return }
        Reception.shared.paywalls = placements
        Reception.shared.paywallAvailability = { id in await isAvailable(id) }
        Reception.shared.onPaywall = { id in Superwall.shared.register(placement: id) }
        // Recheck once Superwall has loaded and after purchases.
        observer = Superwall.shared.$subscriptionStatus
            .combineLatest(Superwall.shared.$configurationStatus)
            .sink { @Sendable _ in Task { @MainActor in Reception.shared.refreshPaywalls(invalidate: true) } }
    }

    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
    }
}
```

## 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`:

```swift
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.
- More paywalls: add one entry per paywall to `paywalls`, and switch on the id in `onPaywall` and in the availability check, each with its own product IDs.
- The file owns `paywalls`, `paywallAvailability` and `onPaywall`. Don’t set them elsewhere.

```swift
import StoreKit
import SwiftUI
import UIKit
import ReceptionKit

/// 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.
@MainActor
enum ReceptionStorePaywall {
    /// The products your paywall sells.
    private static let productIDs: Set<String> = ["pro_monthly", "pro_yearly"]
    private static var updatesTask: Task<Void, Never>?

    static func start() {
        guard updatesTask == nil else { return }
        Reception.shared.paywalls = [ReceptionPaywall(id: "plans", title: "Plans")]
        Reception.shared.paywallAvailability = { _ in await isAvailable() }
        Reception.shared.onPaywall = { _ in presentAboveChat(YourPaywallView()) }
        // Your purchase code still finishes transactions; this only rechecks the card.
        updatesTask = Task {
            for await _ in Transaction.updates { Reception.shared.refreshPaywalls(invalidate: true) }
        }
    }

    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)`.

## Send and measure

1. Open the customer’s latest iOS conversation in **Inbox**. Select **Reopen** if it is closed, then **Send offer**, the lock beside **Ask for review**.
2. In **Send an offer**, choose a **Paywall** and **Button** label. Owners manage the app’s offer labels in **Settings → Review & Offer CTAs → Paywalls & discount offers**.
3. 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**.
4. 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.
5. 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. App settings → **Chat actions** shows 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](https://developer.apple.com/app-store/review/guidelines/#payments). Offer pushes are off by default. Apple requires explicit consent in your app’s UI for promotional notifications and an in-app way to opt out; see [guideline 4.5.4](https://developer.apple.com/app-store/review/guidelines/#4.5.4).

## Verify on a device

1. Open support in the app, then send yourself a card from the dashboard. The **Send an offer** dialog lists your offerings, placements or paywalls.
2. Tap the card’s button: the paywall opens above the chat. Close it: you are back in the conversation.
3. 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. With RevenueCat, conversation details show your `plan`.
4. 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. Relaunch or foreground the app; offerings may be cached. |
| 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 | Offer pushes are off unless **Send a push notification** is on for the app. Also check [push eligibility](/docs/push-notifications). |
