<!-- AI agents: this is the Markdown version of an AppNorth documentation page. Index: https://www.appnorth.app/llms.txt -->

# Paywall cards

A support agent can send an offer card in an iOS chat. The customer sees an optional message and a button that opens your app's RevenueCat paywall. AppNorth records the first tap, not a purchase. Your existing purchase flow owns the transaction.

## Add the optional helper {#helper}

The AppNorth SDK does not depend on RevenueCat. Add `RevenueCat` and `RevenueCatUI` from purchases-ios **5.50 or later in the 5.x line**, then copy `packages/ios/Helpers/AppNorthRevenueCatPaywalls.swift` from the integration sources into your app target. This helper is separate source, not a type exported by the AppNorth binary.

Configure `Purchases`, sign in to the correct RevenueCat account, and install the corresponding AppNorth identity credential before creating the helper. Retain it for the session rather than recreating it in `body`.

```swift title="OfferSession.swift"
import AppNorth

@MainActor
final class OfferSession {
    let helper: AppNorthRevenueCatPaywalls
    var syncFailed = false

    init(client: AppNorthClient) {
        helper = AppNorthRevenueCatPaywalls(client: client)
        helper.onError = { [weak self] _ in self?.syncFailed = true }
    }

    func stop() {
        helper.stop()
    }
}
```

This snippet requires the copied helper in the same target. Its optional `presenter:` closure returns the UIKit controller to present above. Supply it for multiwindow apps; the default searches the active scene's key window and top presented controller.

## Connect it to chat {#chat}

Pass `helper.paywalls` to `AppNorthChatView` or to `AppNorthView` for its floating chat entry. The example below works with either the helper's controller or a controller you configure yourself:

```swift title="OffersChat.swift"
import SwiftUI
import AppNorth

struct OffersChat: View {
    let client: AppNorthClient
    let paywalls: AppNorthPaywalls

    var body: some View {
        AppNorthChatView(client: client, paywalls: paywalls)
    }
}
```

The helper loads all offerings at startup and on foreground, observes `customerInfoStream`, and presents `PaywallView` above chat. It dismisses on purchase completion, explicit close, or a restore with an active entitlement. Configure a usable close control in your paywall design and test returning to the conversation.

Offerings without a published paywall are not presentable. A card becomes unavailable when its offering disappears or none of its products remain available. Consumables never count as owned; subscriptions use active product IDs, allowing an offering with other plans to remain available for a plan change.

## Report your own catalog {#manual}

If your app already owns paywall presentation, retain an `AppNorthPaywalls` controller on the main actor. Pass real localized prices and ISO-8601 periods from your products, not invented display values.

```swift title="ConfigurePaywalls.swift"
import AppNorth

@MainActor
func configurePaywalls(
    controller: AppNorthPaywalls,
    catalog: [AppNorthPaywall],
    availability: @escaping (String) async -> Bool,
    present: @escaping (String) -> Void
) async throws {
    try controller.setPaywalls(catalog)
    controller.availabilityProvider = availability
    controller.onPaywall = present
    try await controller.refreshPaywalls(invalidate: true)
}
```

Call `refreshPaywalls(invalidate: true)` after purchases or customer state changes. Overlapping refreshes coalesce. A missing catalog entry, an unsupported card kind, a non-presentable offering, or a missing presentation callback keeps a card disabled.

Snapshots allow 20 paywalls, 10 products per paywall, and 40 products total. Paywall IDs are 1–64 characters from `A-Z a-z 0-9 _ . -`; titles are nonempty and at most 60 UTF-16 units. Titles are team-only labels. Product prices are localized strings; periods and trials use forms such as `P1M` and `P7D`, with `P0D` for a one-time product. Invalid or oversized snapshots throw instead of silently dropping offerings.

## Metadata and receipts {#metadata}

By default the helper reports device-derived `subscription_status`, `plan`, `renews_on` or `expires_on`, and `environment: sandbox` when applicable. Pass `syncsSubscriptionMetadata: false` to omit them. These are separate from signed identity properties and must not be used as trusted authorization claims. If several subscriptions are active, the displayed plan is the first product ID alphabetically; availability still checks all products.

`onOfferOpened` receives the message ID and paywall ID once per card in the controller. Repeated taps may reopen the paywall, while server receipts count the first tap once. A failed receipt retries on the next tap or refresh while the controller lives. This callback does not confirm presentation or purchase completion. Cards arrive through chat polling; this SDK has no push delivery API.

## Change accounts safely {#account-changes}

Call `helper.stop()`, close chat, and discard the helper **before** RevenueCat logout or an account switch. Update RevenueCat and AppNorth's credential, then create a new helper. Do not reuse the previous user's catalog or subscription metadata. The controller also invalidates availability when it detects an identity-scope change.

## Verify {#verify}

1. Open chat with the retained helper and confirm the device's actual offerings appear in the dashboard's **Send offer** flow.
2. Send a card, tap it, close the paywall, and confirm you return to chat.
3. Complete a sandbox purchase and verify availability refreshes from RevenueCat. Use RevenueCat to verify the transaction; an AppNorth open receipt measures only the tap.
4. Tap repeatedly and confirm one open receipt. Test a removed offering, a missing callback, and a failed receipt followed by reconnecting.
5. Switch accounts and verify neither the old catalog state nor subscription metadata is reused.
