> ## Documentation Index
> Fetch the complete documentation index at: https://docs.encorekit.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Add Subscription Product with Qonversion

> Route the purchase Encore requests through Qonversion, and give the product an entitlement so it unlocks something.

## Overview

You still pick the product in App Store Connect and the Encore Dashboard exactly as in [Add Subscription Product](/publishers/ios/quickstart/add-subscription-product). What changes with Qonversion is which billing code your [`EncorePurchaseController`](/publishers/ios/sdk-reference/purchase-controller) calls.

Qonversion ships no paywall component, so [Presenting Offers](/publishers/ios/quickstart/present-offers) needs no change: call `show()` from your own UI.

***

## Add the product to Qonversion first

Creating the product in App Store Connect is not enough. Qonversion keeps its own product catalog, and the mapping from a product to an entitlement lives there rather than in the store.

<Steps>
  <Step title="Create the product">
    In the Qonversion dashboard, create a product whose store identifier matches the one in App Store Connect.
  </Step>

  <Step title="Attach it to an entitlement">
    Open the **entitlement** the product should unlock and add the product to it. The association is only editable from the entitlement side: Qonversion's `POST /products` has no entitlement field, and the API equivalent is the entitlement's `product_ids` list.
  </Step>
</Steps>

<Note>
  Qonversion fails early when you skip the first step, which makes this easier to catch than the equivalent gap in other managers. A product Qonversion does not know about is rejected with a typed `QONErrorCodeProductNotFound (2)` on your first test purchase rather than in a support ticket months later.
</Note>

<Warning>
  The second step fails quietly instead. A product that exists in Qonversion but belongs to no entitlement is purchasable, and the purchase completes, but it grants nothing. The snippet below guards against that by reading the returned entitlements.
</Warning>

***

## Route the purchase through Qonversion

Qonversion's iOS purchase API is completion-based, so the controller bridges it with `withCheckedThrowingContinuation` before it can return the `EncorePurchaseResult` Encore expects:

```swift theme={null}
import Qonversion

final class AppPurchases: EncorePurchaseController {
    func purchase(_ request: PurchaseRequest) async throws -> EncorePurchaseResult {
        // Bridge Qonversion's completion into the result Encore expects.
        // A cancel is not a purchase: report .cancelled, never .purchased.
        return try await withCheckedThrowingContinuation { continuation in
            Qonversion.shared().purchase(request.productId) { entitlements, error, cancelled in
                if let error { continuation.resume(throwing: error) }
                else { continuation.resume(returning: cancelled || entitlements.isEmpty ? .cancelled : .purchased) }
            }
        }
    }
}

Encore.shared.configure(apiKey: "pk_live_your_key", purchaseController: AppPurchases())
```

<Note>
  The `entitlements.isEmpty` check is deliberate. Qonversion hands you the granted entitlements in the same callback, so the controller can tell "bought and unlocked" from "bought and unlocked nothing" without a second call. Reporting `.purchased` for an empty set would record a conversion for a user who received no access.
</Note>

<Warning>
  An empty entitlement set is also the correct state for a consumable, which is not meant to grant ongoing access. If you sell consumables through this controller, branch on the product id rather than treating every empty set as a cancel.
</Warning>

***

## Promotional offers

A discount hangs off the product's `SKProduct`, so the promotional path needs the product object rather than the id. Signing is completion-based too, and a nil offer is not an error: it means this user buys at list price.

```swift theme={null}
let products = try await Qonversion.shared().products()
guard let product = products.first(where: { $0.value.storeID == request.productId })?.value else {
  throw PurchaseError.notFound
}

let options = Qonversion.PurchaseOptions()
if let offerId = promoOfferId,
   let discount = product.skProduct?.discounts.first(where: { $0.identifier == offerId }) {
    options.promoOffer = await withCheckedContinuation { continuation in
        Qonversion.shared().getPromotionalOffer(for: product, discount: discount) { offer, _ in
            continuation.resume(returning: offer)
        }
    }
}
```

Purchase with `Qonversion.shared().purchaseProduct(product, options: options)` and bridge the completion exactly as in the plain path.

***

## Next

* [Configure Analytics](./configure-analytics) - keep both Qonversion and Encore receiving subscription events
