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

# Updating to 2.x

> Step-by-step migration guide for upgrading the Encore iOS SDK from 1.x to 2.x.

## Overview

SDK 2.0 is a breaking release that makes the integration surface simpler and harder to misuse:

* **`show()` returns a result and never throws.** Errors arrive as values on the result instead of as thrown exceptions.
* **Purchases run through a registered `EncorePurchaseController`.** The SDK never runs purchase code you didn't write.
* **Global handler closures and delegates are retired.** Results are values at the call site; passive observation moves to the `Encore.shared.outcomes` stream.
* **The public API is `@MainActor`.** Results and callbacks always land on the main actor.

***

## Step 1: Update the package

In Xcode, select the Encore package under **Package Dependencies** and change its version requirement to **Up to Next Major Version** from `2.0.0`. In a `Package.swift` manifest:

```swift theme={null}
.package(url: "https://github.com/EncoreKit/ios-sdk", from: "2.0.0")
```

2.0 removes the 1.x legacy surface outright rather than deprecating it, so every call site that needs attention fails to compile. Build the project: the compile errors that appear are your migration worklist, and each one corresponds to a step below. Work through them until the project builds, then run the [verification checklist](#verify-the-migration). The [removed API table](#removed-apis-at-a-glance) maps every 1.x symbol to its replacement.

***

## Step 2: Register a purchase controller

This is the largest change. In 1.x, purchases ran through the `onPurchaseRequest` closure, and if no handler was registered the SDK bought the product itself through StoreKit and reported it via `onPurchaseComplete`. 2.0 removes the closure, the completion callback, and the built-in StoreKit fallback: that fallback bypassed whatever receipt validation, restore handling, and entitlement bookkeeping your app already has, and it made a missing registration look like a working purchase instead of a misconfiguration.

Conform a small class to [`EncorePurchaseController`](/publishers/ios/sdk-reference/purchase-controller) and pass it to [`configure(...)`](/publishers/ios/sdk-reference/configure):

<CodeGroup>
  ```swift RevenueCat theme={null}
  final class AppPurchases: EncorePurchaseController {
      func purchase(_ request: PurchaseRequest) async throws -> EncorePurchaseResult {
          let products = try await Purchases.shared.products([request.productId])
          guard let product = products.first else { throw PurchaseError.productNotFound }
          let result = try await Purchases.shared.purchase(product: product)
          return result.userCancelled ? .cancelled : .purchased
      }
  }

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

  ```swift StoreKit 2 theme={null}
  final class AppPurchases: EncorePurchaseController {
      func purchase(_ request: PurchaseRequest) async throws -> EncorePurchaseResult {
          let products = try await Product.products(for: [request.productId])
          guard let product = products.first else { throw PurchaseError.productNotFound }
          switch try await product.purchase() {
          case .success(let verification):
              if case .verified(let transaction) = verification { await transaction.finish() }
              return .purchased
          case .userCancelled: return .cancelled
          case .pending: return .pending
          @unknown default: return .cancelled
          }
      }
  }

  Encore.shared.configure(apiKey: "pk_...", purchaseController: AppPurchases())
  ```
</CodeGroup>

Return `.purchased` on success, `.cancelled` when the user backs out, and `.pending` for deferred flows such as Ask to Buy; throw for real failures.

<Note>
  The controller registration is build-time wiring, not user state: it survives `reset()`, so logging a user out does not require re-registering.
</Note>

If your app uses Encore offers without a subscription product, no controller is needed and no purchase is ever attempted. If a product is configured but no controller is registered, the claim completes with `publisher: .notAttempted`, the SDK logs a warning naming the product, and the `sdk_iap_no_purchase_controller` analytics event makes the misconfiguration visible.

***

## Step 3: Update show() call sites

`show()` no longer throws. Errors arrive as `.notPresented(.error(EncoreError))` on the [`PresentationResult`](/publishers/ios/sdk-reference/presentation-result), so `do`/`catch` blocks around it are dead code:

```swift theme={null}
// 1.x
do {
    let result = try await Encore.placement("paywall").show()
    if case .granted = result { unlock() } else { runOriginalFlow() }
} catch {
    runOriginalFlow()   // often forgotten, causing a silent hang
}

// 2.0
let result = await Encore.placement("paywall").show()
if result.claim != nil || result.publisher == .purchased { unlock() } else { runOriginalFlow() }
```

Fire-and-forget presentation becomes `show(resume:)`, which delivers **every** outcome (including `.notPresented`) on the main actor:

```swift theme={null}
// 1.x: every error and result discarded
Encore.placement("paywall").show()

// 2.0: every outcome delivered
Encore.placement("paywall").show { result in
    if result.claim == nil && result.publisher != .purchased { runOriginalFlow() }
}
```

The public API is now `@MainActor`. Calls from a background queue or a nonisolated helper no longer compile; move them to the main actor. SwiftUI actions, `.task` blocks, and UIKit handlers are already there.

***

## Step 4: Update result handling

1.x modeled the result as a flat enum (`.purchased`, `.granted(...)`, `.notGranted(reason)`), which forced a claim and a purchase to compete for a single winning case. 2.0 splits the result into `.notPresented(reason)` and `.presented(Outcome)`; see [`PresentationResult`](/publishers/ios/sdk-reference/presentation-result) for the full shape.

The record carries **raw facts only**: 2.0 removes the SDK-computed "unlocked" verdict, so your integration decides what a claim means. Most 1.x switches collapse to a one-line check on the facts:

```swift theme={null}
let result = await Encore.placement("paywall").show()
if result.claim != nil || result.publisher == .purchased {
    // The user claimed an offer or completed your purchase.
    // Read the concrete entitlement from isActive / isActivePublisher.
} else {
    result.error.map(log)   // presentation failures are values
    runOriginalFlow()       // dismissed, declined, no offers, control cohort, ...
}
```

`result.claim` surfaces the claimed offer (`campaignId`, `advertiserName`, `transactionId`) whenever the claim funnel reached `.claimed` or `.verified`; read `result.advertiser` directly to distinguish the two. `result.publisher` reports what your purchase code returned.

When the mechanism detail matters, switch on the record:

```swift theme={null}
switch result {
case .presented(let outcome):
    analytics.log(outcome.advertiser, outcome.publisher, outcome.dismissal)
case .notPresented(let reason):
    analytics.log(reason)
}
```

<Note>
  The 1.x result never carried the entitlement payload correctly across modes, so 2.0 removes it from the result entirely. The granted entitlement is always read from `isActive` / `isActivePublisher`, the authoritative state.
</Note>

***

## Step 5: Replace global callbacks with the outcomes stream

`onPassthrough`, `EncoreDelegate`, `encoreSheet(onGranted:)`, and the builder and manager `onGranted` / `onNotGranted` callbacks are removed. Handle fallback inline at the call site (Step 4), and move passive observation (analytics, logging, cross-cutting state) to the [`Encore.shared.outcomes`](/publishers/ios/sdk-reference/outcomes) stream:

```swift theme={null}
Task {
    for await outcome in Encore.shared.outcomes {
        switch outcome {
        case .presentation(let placementId, let result):
            analytics.log("encore_outcome", placementId, result)
        case .strictUnlockVerified:
            // Entitlements already refreshed. Re-check isActive / isActivePublisher.
            break
        }
    }
}
```

***

## Removed APIs at a glance

| 1.x surface                                                             | 2.0 replacement                                                                                                                  |
| :---------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------- |
| `show() async throws -> PresentationResult`                             | `show() async -> PresentationResult`; errors arrive as `.notPresented(.error(EncoreError))`                                      |
| Fire-and-forget `show()`                                                | `show(resume:)`, which delivers every outcome on the main actor                                                                  |
| `onPurchaseRequest` (both overloads)                                    | `EncorePurchaseController` registered via `configure(apiKey:purchaseController:options:)`                                        |
| `onPurchaseComplete` + built-in StoreKit fallback                       | Removed; your controller owns the purchase, so you already have the result                                                       |
| `onPassthrough { }`                                                     | No claim and no purchase on the record (`result.claim == nil && result.publisher != .purchased`) handled inline at the call site |
| Builder `.onGranted { }` / `.onNotGranted { }`                          | `await show()` + the record's raw facts (`claim`, `advertiser`, `publisher`, `dismissal`)                                        |
| `EncoreDelegate`, `entitlementsDelegate`, `encoreSheet(onGranted:)`     | `Encore.shared.outcomes` stream + `isActivePublisher`                                                                            |
| `.purchased` / `.granted(entitlement)` flat cases                       | `.presented(Outcome)` with independent `advertiser`, `publisher`, `dismissal` fields                                             |
| `.notGranted(reason)`                                                   | `.presented(Outcome)` with `outcome.dismissal: DismissReason`                                                                    |
| `.notGranted(.noOffersAvailable / .unsupportedOS / .experimentControl)` | `.notPresented(.noOffers / .unsupportedOS / .experimentControl)`: these describe a sheet that never appeared                     |
| `.notGranted(.userTappedOutside)`                                       | Removed with no replacement; no SDK version ever produced it                                                                     |
| Errors thrown from `show()`                                             | `.notPresented(.error(...) / .notConfigured / .alreadyPresenting)`                                                               |
| `configure(apiKey:logLevel:unlock:)`                                    | `configure(apiKey:options:)` or `configure(apiKey:purchaseController:options:)`                                                  |

The deprecated typealiases `NotGrantedReason` and `EncorePresentationResult` survive as zero-cost renames that Xcode fix-its resolve.

***

## Behavior changes to be aware of

These compile fine but behave differently at runtime:

* **Errors no longer throw from `show()`.** Old `catch` fallback blocks are dead code; run fallback off `.notPresented` instead.
* **Claim-then-purchase records both funnels.** 1.x collapsed the flow to a single winner. 2.0 carries `advertiser: .claimed(...)` and `publisher: .purchased` side by side, and the result is delivered once, after the purchase resolves (later than 1.x fired its callbacks).
* **Strict mode records the claim, and verification upgrades it.** The record says `advertiser: .claimed(...)` immediately (and `result.claim` surfaces it); in-session verification upgrades it to `.verified(...)`, and late completion arrives as `.strictUnlockVerified` on `Encore.shared.outcomes`, surviving process death. Gate concrete access on `isActive` / `isActivePublisher`.
* **Callbacks and resumes always land on the main actor.** 1.x invoked them from arbitrary contexts.

***

## New in 2.0, with no migration required

Existing call sites keep working unchanged. These arrive on top:

* **A claim-only reward surface.** A placement can select [`.useCase(.rewardUsers)`](/publishers/ios/sdk-reference/placement#use-cases) to present *after* the user has already done something worth celebrating, such as a completed purchase, a milestone, or a streak. It never runs a purchase and never falls back to the paywall sheet. Placements that omit `useCase(_:)` keep the default `.reduceChurn` surface.
* **Per-presentation copy overrides.** `headline(_:)` and `subheadline(_:)` replace the sheet's shipped copy for one presentation. You pass the whole string, so localization and pluralization stay yours.
* **A runtime claim gate.** [`Encore.shared.isClaimEnabled`](/publishers/ios/sdk-reference/is-claim-enabled) dims the claim CTA and stops it responding to taps, leaving the rest of the presentation intact.
* **No more wedged presentations.** A dead flow can no longer brick presentation with `.alreadyPresenting`: every flow resolves on a real event.
* **Durable strict-mode claims.** A strict-mode claim that ends unverified persists across launches until verification completes.

```swift theme={null}
let result = await Encore.shared.placement("streak_complete")
    .useCase(.rewardUsers)
    .headline("That's three days in a row staying informed 🔥")
    .subheadline("Here's a little thank you from us")
    .show()
```

***

## Verify the migration

<Check>
  Confirm each of these before shipping:
</Check>

1. The project compiles with no references to removed 1.x APIs.
2. If a subscription product is configured for your app, an `EncorePurchaseController` is registered at `configure` time.
3. Every fallback path (your original paywall or flow) runs off the no-claim, no-purchase branch (`result.claim == nil && result.publisher != .purchased`), not a `catch` block.
4. Test the full flow on a device: present a placement, claim an offer, complete a purchase, and dismiss without acting. Each path should resolve exactly once with the result you expect.

***

If you hit anything this guide doesn't cover, reach out to [admin@encorekit.com](mailto:admin@encorekit.com).
