# Consent management (CMP)

Source: https://docs.ezoic.com/docs/mobileapps/cmp/


Ezoic's mobile app SDKs include a consent management platform (CMP). It asks users for the consent that privacy laws require and stores their choices in the standard IAB keys that Google Mobile Ads, Prebid and other ad SDKs read. Your Ezoic settings for your domain decide what it shows.

Apps that show Ezoic ads get the CMP through the ads SDK, with nothing extra to install. From SDK 1.22.0, Ezoic publishers whose app doesn't use Ezoic ads can install the CMP on its own (see [Use the CMP without Ezoic ads](#use-the-cmp-without-ezoic-ads)).

## What the CMP does

What the user sees depends on where they are:

- **GDPR regions:** an IAB TCF 2.4 consent dialog (CMP ID 299). The decision is stored as the standard `IABTCF_*` keys, and ad loads wait until the user decides.
- **Covered US states:** a privacy notice and a "Do Not Sell My Information" choice, stored as the standard IAB GPP keys (`IABGPP_*`) for the user's state. Ads never wait for it. See [US-state privacy](#us-state-privacy).
- **Everywhere else:** nothing is shown, and ads load as usual.

The keys are written to the app's default `SharedPreferences` on Android and to `UserDefaults.standard` on iOS.

## Use the CMP with Ezoic ads

If your app uses an Ezoic ads SDK, the CMP is already included and on by default (`cmpEnabled` is `true`). You don't install anything else. Use the consent methods on `EzoicAds`:

- **`presentConsentIfRequired`** shows the GDPR dialog when the user must decide. Call it once per launch, right after `initialize`. The Flutter, React Native and Capacitor SDKs call it for you after `initialize` unless you set `autoPresentConsent` to `false`.
- **`presentConsentSettings`** reopens the user's privacy choices. Call it from your "Privacy choices" entry point (see [Add a "Privacy choices" entry point](#add-a-privacy-choices-entry-point)).
- **`isPrivacyOptionsRequired`** tells you whether to show that entry point.
- **`isConsentRequired`** tells you whether GDPR applies and the built-in CMP is in charge.
- **`resetConsent`** deletes the stored decision and the US choice, so the dialog or US notice shows again.

Each platform guide covers its version of these calls, outcomes and options:

- [Android](/docs/mobileapps/android/#privacy-and-consent)
- [iOS](/docs/mobileapps/ios/#privacy-and-consent)
- [Flutter](/docs/mobileapps/flutter/#privacy-and-consent)
- [React Native](/docs/mobileapps/react-native/#privacy-and-consent)
- [Capacitor](/docs/mobileapps/capacitor/#privacy-and-consent)
- [Unity](/docs/mobileapps/unity/#privacy-and-consent)

## US-state privacy

From SDK 1.22.0, the CMP handles the US states with consumer privacy laws the same way Ezoic's [web CMP](/docs/privacy/gpp/) does. The covered states are California, Colorado, Connecticut, Delaware, Florida, Iowa, Montana, Nebraska, New Hampshire, New Jersey, Oregon, Tennessee, Texas, Utah and Virginia.

There is nothing to call and no new setting. It runs whenever the built-in CMP is on, and `cmpEnabled: false` turns it off along with the GDPR dialog.

- **Notice.** At launch, a card slides up from the bottom of the screen with the heading "Privacy Preferences" and two buttons, **Consent** and **Do Not Sell My Information**. The card has no backdrop, so the rest of your app stays usable. Only a choice dismisses it. Until then it follows the user from screen to screen, and it never sits over a full-screen ad or one of the SDK's own screens. While it is up, Ezoic anchor ads are hidden. If the user leaves the app without choosing, the card shows again at the next launch. After a choice it isn't shown again, unless the choice is cleared with `resetConsent` or the user moves to a different covered state. The notice is in English.
- **Do Not Sell.** **Do Not Sell My Information** opens the US privacy settings: a full screen with one "Do not sell or share my information" toggle and a **Save Settings** button. `presentConsentSettings` opens the same screen.
- **Signals.** The CMP writes the GPP section for the user's state. Until the user opts out, the keys say "not opted out", with the notice given. After an opt-out, the keys say so, and Google Ad Manager requests ask for restricted data processing.
- **Ads aren't blocked.** US privacy laws are opt-out, so ad loads never wait for the notice or the user's choice.
- **`presentConsentIfRequired`** is about GDPR only. In a covered US state it returns `NotRequired`, because the CMP shows the US notice by itself.
- **Hands off.** The CMP writes no `IABGPP_*` keys and shows no US notice when another CMP has already written `IABGPP_*` keys, when your app passes GPP itself with `setGPPConsent`, or when `autoReadConsent` is `false`. When the user is no longer in a covered state, it removes only the keys it wrote.

## Add a "Privacy choices" entry point

Users must be able to change their choice at any time. TCF policy requires a way to reopen the dialog and withdraw consent, and US users need a way to opt out. Add a button or menu item, for example "Privacy choices" in your settings screen, that calls `presentConsentSettings`.

`isPrivacyOptionsRequired` (SDK 1.22.0 and later) tells you whether that entry point is needed. It works like Google UMP's `privacyOptionsRequirementStatus`:

| Value | Meaning |
| --- | --- |
| `true` | The user is in a GDPR region or a covered US state, and the built-in CMP handles consent |
| `false` | No entry point is needed: the user is elsewhere, the built-in CMP is off, or another CMP or your app manages consent |
| `null` / `nil` | Not known yet. The SDK hasn't finished loading your settings |

**Show the entry point unless the value is `false`.** A `null` value means "not known yet", so don't hide the entry point because of it. Check the value again later, for example each time the screen with the entry point appears. Tapping the entry point when there is nothing to show is harmless: `presentConsentSettings` returns `NotRequired`.

```kotlin
// Android (Kotlin)
privacyButton.isVisible = EzoicAds.instance.isPrivacyOptionsRequired != false
privacyButton.setOnClickListener {
    EzoicAds.instance.presentConsentSettings(this) { outcome -> /* ... */ }
}
```

```swift
// iOS
privacyButton.isHidden = EzoicAds.shared.isPrivacyOptionsRequired == false

@objc func privacyChoicesTapped() {
    EzoicAds.shared.presentConsentSettings(from: self) { outcome in /* ... */ }
}
```

| Platform | With Ezoic ads | Standalone CMP |
| --- | --- | --- |
| Android (Kotlin) | `EzoicAds.instance.isPrivacyOptionsRequired` | `EzoicCmp.isPrivacyOptionsRequired` |
| Android (Java) | `EzoicAds.getInstance().isPrivacyOptionsRequired()` | `EzoicCmp.isPrivacyOptionsRequired()` |
| iOS | `EzoicAds.shared.isPrivacyOptionsRequired` | `EzoicCmp.isPrivacyOptionsRequired` |
| Flutter | `EzoicAds.isPrivacyOptionsRequired()` | `EzoicCmp.isPrivacyOptionsRequired()` |
| React Native | `EzoicAds.isPrivacyOptionsRequired()` | `EzoicCmp.isPrivacyOptionsRequired()` |
| Capacitor | `EzoicAds.isPrivacyOptionsRequired()` | `EzoicCmp.isPrivacyOptionsRequired()` |
| Unity | `EzoicAds.IsPrivacyOptionsRequired` | `EzoicCmp.IsPrivacyOptionsRequired` |

The Flutter, React Native and Capacitor calls return a future or promise.

Don't decide on `isConsentRequired` instead. It answers only whether GDPR applies, so it stays `false` for users in covered US states.

## What presentConsentSettings opens

From SDK 1.22.0, `presentConsentSettings` opens the screen for where the user is, so an existing "Privacy settings" button works for US users without code changes:

| Where the user is | What opens | Outcomes |
| --- | --- | --- |
| GDPR region | The TCF dialog, with the user's stored choices | As before: `Decided(ACCEPT_ALL / REJECT_ALL / CUSTOM)` or `Dismissed`. If the dialog can't load (for example offline) and a still-valid decision is stored, `AlreadyDecided` |
| Covered US state | The US privacy settings | `Decided(ACCEPT_ALL)`: not opted out. `Decided(REJECT_ALL)`: opted out of the sale or sharing of their information. `Dismissed`: closed without saving |
| Elsewhere, the CMP is off, or another CMP or your app manages consent | Nothing | `NotRequired` |

The outcome names follow each platform's style, for example `.decided(.rejectAll)` on iOS and `{ type: 'decided', decision: 'rejectAll' }` in React Native and Capacitor. SDK 1.22.0 adds no new outcomes.

## Use the CMP without Ezoic ads

From SDK 1.22.0, the CMP also ships on its own, for **Ezoic publishers whose app doesn't use Ezoic ads**. It gives the app the same GDPR and US-state handling it would get through `EzoicAds`, driven by the same Ezoic settings for your domain. The standalone entry point, `EzoicCmp`, has the same consent methods and outcomes as `EzoicAds`.


**Ezoic publishers only.** `domain` must be a domain in your Ezoic account, the same value the ads SDK takes. Ezoic refuses other domains, and the CMP then does nothing: every present call fails with code `1001` (not initialized), `isConsentRequired` and `isPrivacyOptionsRequired` stay `null`, no US notice is shown, and nothing is written.



**Use one entry point.** If your app uses Ezoic ads, use `EzoicAds` and don't add the standalone package: the ads SDK already includes the CMP. If an app initializes both `EzoicAds` and `EzoicCmp`, in either order, `EzoicAds` takes over the same CMP, the stored decision carries over, and `EzoicCmp.initialize` is ignored. If both packages are installed, use the same version of each. Each native library is still linked once.


### Install

The standalone CMP is version 1.22.0 on every platform. It needs the same OS versions as the ads SDKs: Android SDK 24 or higher, and iOS 15.0 or higher with Xcode 26.2 or higher.

**Android.** Add the dependency from Maven Central:

```kotlin
dependencies {
    implementation("com.ezoic.sdk:ezoic-cmp-sdk:1.22.0")
}
```

**iOS.** Use Swift Package Manager. In Xcode, go to **File > Add Package Dependencies**, enter `https://github.com/ezoic/ezoic-swift-sdk-dist.git` with **Up to Next Major Version** from `1.22.0`, and add the `EzoicCmpSDK` product (not `EzoicAdsSDK`) to your app target. Or in `Package.swift`:

```swift
dependencies: [
    .package(url: "https://github.com/ezoic/ezoic-swift-sdk-dist.git", from: "1.22.0")
],
targets: [
    .target(
        name: "MyApp",
        dependencies: [
            .product(name: "EzoicCmpSDK", package: "ezoic-swift-sdk-dist")
        ]
    )
]
```

Then write `import EzoicCmpSDK`. There is no CocoaPods pod for the native iOS CMP.

**Flutter.** Add `ezoic_flutter_cmp_sdk` from the Flutter SDK repository, pinned to the release tag:

```yaml
dependencies:
  ezoic_flutter_cmp_sdk:
    git:
      url: https://github.com/ezoic/flutter-sdk.git
      ref: v1.22.0
      path: ezoic_flutter_cmp_sdk
```

It works with Swift Package Manager and with CocoaPods. With CocoaPods, set `platform :ios, '15.0'` in `ios/Podfile`. `pod install` then downloads the native CMP framework from the SDK's GitHub release and checks its SHA-256, so the first `pod install` needs network access to GitHub.

**React Native.** React Native 0.76 or higher with the New Architecture:

```sh
npm install @ezoic/react-native-cmp-sdk@1.22.0
cd ios && pod install
```

The native iOS CMP ships inside the npm package, so no `Podfile` changes are needed.

**Capacitor.** Capacitor 7 or 8:

```sh
npm install @ezoic/capacitor-cmp-sdk@1.22.0
npx cap sync
```

With Swift Package Manager, `npx cap sync` adds the `EzoicCmpSDK` product. With CocoaPods, the plugin's pod uses the native CMP framework that ships in the npm package.

**Unity.** Unity 2021.3 LTS or higher. Open **Window → Package Manager → + → Add package from git URL…** and enter:

```
https://github.com/ezoic/ezoic-unity-sdk.git?path=/com.ezoic.cmp-sdk~#v1.22.0
```

Or add it to `Packages/manifest.json`:

```json
{
  "dependencies": {
    "com.ezoic.cmp-sdk": "https://github.com/ezoic/ezoic-unity-sdk.git?path=/com.ezoic.cmp-sdk~#v1.22.0"
  }
}
```

With the External Dependency Manager (Unity's EDM 2.1 or higher, or EDM4U 1.2.189 or higher), the Android Resolver adds `com.ezoic.sdk:ezoic-cmp-sdk` to your Gradle build. The iOS Resolver adds the `ezoic-swift-sdk-dist` Swift package with its `EzoicCmpSDK` product to `UnityFramework`. Keep **Swift Package Manager Integration** on in the iOS Resolver settings; there is no CocoaPod. Without EDM, add `implementation 'com.ezoic.sdk:ezoic-cmp-sdk:1.22.0'` to a custom main Gradle template, and add the Swift package's `EzoicCmpSDK` product to the `UnityFramework` target in Xcode.

### Initialize and show the dialog

Initialize once, early, with your Ezoic domain. `initialize` returns at once and loads your settings in the background. Then present the dialog from your first screen. The call waits for the settings, and shows the dialog only where GDPR applies and the user hasn't decided. In covered US states, the CMP shows its US notice by itself.

**Android (Kotlin):**

```kotlin
import com.ezoic.ads.sdk.privacy.cmp.ConsentOutcome
import com.ezoic.ads.sdk.privacy.cmp.EzoicCmp

class MyApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        EzoicCmp.initialize(this, "example.com")
    }
}

// In your first Activity
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    EzoicCmp.presentConsentIfRequired(this) { outcome ->
        when (outcome) {
            is ConsentOutcome.Decided -> Log.d("Ezoic", "User chose ${outcome.decision}")
            is ConsentOutcome.Failed -> Log.w("Ezoic", "Consent UI failed: ${outcome.error.message}")
            else -> Unit // NotRequired, AlreadyDecided, Dismissed, AlreadyPresenting
        }
    }
}

// "Privacy choices" entry point
privacyButton.isVisible = EzoicCmp.isPrivacyOptionsRequired != false
privacyButton.setOnClickListener {
    EzoicCmp.presentConsentSettings(this) { outcome -> /* ... */ }
}
```

**Android (Java):**

```java
import com.ezoic.ads.sdk.privacy.cmp.ConsentOutcome;
import com.ezoic.ads.sdk.privacy.cmp.EzoicCmp;

// In Application.onCreate
EzoicCmp.initialize(this, "example.com");

// In your first Activity's onCreate
EzoicCmp.presentConsentIfRequiredWithListener(this, outcome -> {
    if (outcome instanceof ConsentOutcome.Decided) {
        Log.d("Ezoic", "User chose " + ((ConsentOutcome.Decided) outcome).getDecision());
    }
});

// "Privacy choices" entry point
privacyButton.setVisibility(
    Boolean.FALSE.equals(EzoicCmp.isPrivacyOptionsRequired()) ? View.GONE : View.VISIBLE);
privacyButton.setOnClickListener(v ->
    EzoicCmp.presentConsentSettingsWithListener(this, outcome -> { /* ... */ }));
```

**iOS:**

```swift
import EzoicCmpSDK

// At app startup, for example in application(_:didFinishLaunchingWithOptions:)
EzoicCmp.initialize(domain: "example.com")

// In your first view controller
private static var consentRequested = false // once per process

override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)
    guard !Self.consentRequested else { return }
    Self.consentRequested = true
    EzoicCmp.presentConsentIfRequired(from: self) { outcome in
        switch outcome {
        case .decided(let decision):
            print("User chose \(decision)")
        case .failed(let error):
            print("Consent UI failed: \(error.localizedDescription)")
        default:
            break // .notRequired, .alreadyDecided, .dismissed, .alreadyPresenting
        }
    }
}

// "Privacy choices" entry point
override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    privacyButton.isHidden = EzoicCmp.isPrivacyOptionsRequired == false
}

@objc func privacyChoicesTapped() {
    EzoicCmp.presentConsentSettings(from: self) { outcome in /* ... */ }
}
```

`async`/`await` is also supported: `let outcome = await EzoicCmp.presentConsentIfRequired(from: self)`.

**Flutter.** The plugin presents the dialog for you once settings have loaded, unless you set `autoPresentConsent: false`:

```dart
import 'package:ezoic_flutter_cmp_sdk/ezoic_flutter_cmp_sdk.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  runApp(const MyApp());
  await EzoicCmp.initialize('example.com');
}

// "Privacy choices" entry point
FutureBuilder<bool?>(
  future: EzoicCmp.isPrivacyOptionsRequired(),
  builder: (context, snapshot) => snapshot.data != false
      ? TextButton(
          onPressed: EzoicCmp.presentConsentSettings,
          child: const Text('Privacy choices'),
        )
      : const SizedBox.shrink(),
)
```

To choose the timing yourself:

```dart
await EzoicCmp.initialize(
  'example.com',
  const EzoicCmpConfiguration(autoPresentConsent: false),
);
final outcome = await EzoicCmp.presentConsentIfRequired();
```

**React Native:**

```tsx
import { EzoicCmp } from '@ezoic/react-native-cmp-sdk';

// Once, early in app startup
EzoicCmp.initialize('example.com');

// From your first screen
const outcome = await EzoicCmp.presentConsentIfRequired();

// "Privacy choices" entry point
const [showPrivacy, setShowPrivacy] = useState(true);
useEffect(() => {
  EzoicCmp.isPrivacyOptionsRequired().then((v) => setShowPrivacy(v !== false));
}, []);

{showPrivacy && (
  <Button
    title="Privacy choices"
    onPress={() => EzoicCmp.presentConsentSettings()}
  />
)}
```

**Capacitor.** The plugin presents the dialog for you once settings have loaded, unless you set `autoPresentConsent: false`:

```ts
import { EzoicCmp } from '@ezoic/capacitor-cmp-sdk';

await EzoicCmp.initialize({ domain: 'example.com' });

// "Privacy choices" entry point
const button = document.getElementById('privacy-choices')!;
button.hidden = (await EzoicCmp.isPrivacyOptionsRequired()) === false;
button.addEventListener('click', () => {
  EzoicCmp.presentConsentSettings();
});
```

On the web, every call is a no-op: present calls resolve `notRequired`, and the checks resolve `null`.

**Unity:**

```csharp
using Ezoic.Cmp;
using UnityEngine;

public class ConsentBootstrap : MonoBehaviour
{
    void Start()
    {
        EzoicCmp.Initialize("example.com");

        EzoicCmp.PresentConsentIfRequired(result =>
        {
            // result.Outcome: NotRequired, AlreadyDecided, Decided (see result.Decision),
            // Dismissed (ask again later), AlreadyPresenting, or Failed (see result.Error).
            Debug.Log("Consent: " + result);
        });
    }

    // Show the "Privacy choices" button while this is true
    bool ShowPrivacyButton => EzoicCmp.IsPrivacyOptionsRequired != false;

    public void OnPrivacyChoices()
    {
        EzoicCmp.PresentConsentSettings(result => Debug.Log("Privacy settings: " + result));
    }
}
```

Make the first call from the Unity main thread. Results arrive on the main thread. In the editor and on other platforms, calls are safe no-ops: present calls report `Failed`, and the checks return `null`.

Calling `initialize` again is harmless; repeat calls are ignored. Calling `presentConsentIfRequired` again is also harmless: you get `AlreadyPresenting` while a dialog is in flight, and `AlreadyDecided` once a decision is stored.

### Options

Pass options as the configuration argument to `initialize`: `EzoicCmpConfiguration` on Android, iOS, Flutter and Unity, and an options object in React Native and Capacitor.

| Option | Default | Meaning |
| --- | --- | --- |
| `cmpEnabled` | `true` | Run the CMP. Set `false` if your app runs another CMP |
| `autoReadConsent` | `true` | `false` means your app manages consent itself: the CMP never presents anything or writes keys |
| `debugEnabled` | `false` | Also write the CMP's debug and info lines to the platform log (Logcat tag `EzoicCmp`; iOS subsystem `com.ezoic.cmp.sdk`) |
| `logger` | none | Android and iOS only: a `CmpLogger` that receives the CMP's log lines instead of the platform log |
| `autoPresentConsent` | `true` | Flutter and Capacitor only: present the dialog once, if required, right after `initialize` |

Java apps build the configuration with `new EzoicCmpConfiguration.Builder().debugEnabled(true).build()`. Unity uses property names (`CmpEnabled`, `AutoReadConsent`, `DebugEnabled`).

### Outcomes and errors

`presentConsentIfRequired` and `presentConsentSettings` return the same outcomes as on `EzoicAds` (see [What presentConsentSettings opens](#what-presentconsentsettings-opens) and your platform guide). In the wrappers, they never throw or reject.

When the dialog can't be shown, the `Failed` outcome carries a `CmpError` on Android and iOS, and a numeric `code` in the wrappers:

| `CmpError` | `code` | When |
| --- | --- | --- |
| `NotInitialized` | `1001` | Called before `initialize`, settings never loaded, or Ezoic refused the domain. Nothing is written |
| `Network` | `2001` | The consent configuration or vendor list couldn't be loaded |
| `Render` | `4001` | The consent screen couldn't be shown |
| `InvalidConsent` | `5002` | The decision couldn't be encoded or saved |

The wrappers also use `code: -1` when there was no foreground screen to present from. Call `presentConsentIfRequired` again once a screen is showing.

`isConsentRequired` is `null` before `initialize`, while your settings load, and when none could be loaded, for example because the domain was refused or the device is offline with nothing cached. If loading fails after an earlier launch succeeded, the CMP uses the settings it loaded last time.

## Using your own CMP

If your app already runs another CMP, such as Google UMP or OneTrust, turn the built-in CMP off with `cmpEnabled: false` (Java: `.cmpEnabled(false)`, Unity: `CmpEnabled = false`). If you don't use Ezoic ads, don't install the standalone CMP. The ads SDKs then read your CMP's keys as before:

- TCF consent from `IABTCF_*` keys
- GPP consent from `IABGPP_*` keys
- US Privacy consent from the standard IAB US Privacy key

The built-in CMP also stays out of the way by itself:

- It doesn't show the GDPR dialog when it finds `IABTCF_CmpSdkID` set to another CMP's ID.
- It doesn't write `IABGPP_*` keys or show the US notice when another CMP has already written `IABGPP_*` keys.
- It stands down when your app passes consent with `setGDPRConsent` or `setGPPConsent`, or sets `autoReadConsent` to `false`.

Set `cmpEnabled: false` explicitly anyway, so the built-in CMP never acts before your own CMP has written its keys.

## Upgrading to 1.22.0

Most apps need no code changes. Check these:

- **US users now see the notice.** If your app runs its own CMP for US states, make sure `cmpEnabled` is `false`. If your "Privacy settings" button is shown only while `isConsentRequired` is `true`, show it based on `isPrivacyOptionsRequired` instead, so that US users can reach it (see [Add a "Privacy choices" entry point](#add-a-privacy-choices-entry-point)).
- **The CMP is its own library.** On Android, `com.ezoic.sdk:ezoic-ads-sdk` now depends on `com.ezoic.sdk:ezoic-cmp-sdk`, and Gradle adds it for you. If your build excludes transitive dependencies or uses a dependency allow-list, allow `com.ezoic.sdk:ezoic-cmp-sdk` at the same version. On iOS, the `EzoicAdsSDK` Swift package product brings in the CMP. Package and import names don't change: `com.ezoic.ads.sdk.privacy.cmp` on Android, and `import EzoicAdsSDK` on iOS.

### `ConsentOutcome.Failed` carries `Exception` / `Error`

This affects only native Kotlin, Java and Swift code that reads the error from a `Failed` outcome. The Flutter, React Native, Capacitor and Unity outcomes keep their shape and codes.

`ConsentOutcome.Failed.error` was an `EzoicError`. It is now an `Exception` on Android and an `Error` on iOS, because the same outcome type is shared with the standalone CMP. Through `EzoicAds`, the error is still an `EzoicError` at runtime, so `is EzoicError.NotInitialized` checks keep compiling. Through `EzoicCmp`, it is a `CmpError`. Reading `code` now needs a cast:

```kotlin
is ConsentOutcome.Failed -> {
    val code = (outcome.error as? EzoicError)?.code // EzoicCmp: (outcome.error as? CmpError)?.code
    Log.w("Ezoic", "Consent UI failed ($code): ${outcome.error.message}")
}
```

```java
if (outcome instanceof ConsentOutcome.Failed) {
    Exception error = ((ConsentOutcome.Failed) outcome).getError();
    Integer code = error instanceof EzoicError ? ((EzoicError) error).getCode() : null;
}
```

```swift
case .failed(let error):
    // EzoicError and CmpError both conform to EzoicErrorCode
    let code = (error as? EzoicErrorCode)?.code
    print("Consent UI failed (\(String(describing: code))): \(error.localizedDescription)")
```

The codes are unchanged: `1001`, `2001`, `4001` and `5002`, as in [Outcomes and errors](#outcomes-and-errors).

### iOS: module-qualified consent types

`ConsentOutcome` and `ConsentDecisionType` now live in the CMP module. `import EzoicAdsSDK` still makes them available under the same names, but a name qualified with the ads module, such as `EzoicAdsSDK.ConsentOutcome`, no longer compiles. Drop the module prefix and write `ConsentOutcome`.

### CocoaPods

The CocoaPods trunk has no Ezoic SDK newer than 1.19.1, and it becomes read-only on December 2, 2026. From 1.22.0:

- **Native iOS apps:** install with [Swift Package Manager](/docs/mobileapps/ios/#swift-package-manager). The native SDK isn't available as a pod.
- **Flutter and Capacitor:** CocoaPods builds work again and get native SDK 1.22.0. The plugins' pods now include the native frameworks themselves instead of depending on the trunk pod. Flutter downloads them from the SDK's GitHub release when you run `pod install`; Capacitor ships them in the npm package. If your `Podfile` lists `pod 'EzoicAdsSDK'` itself, remove it.
- **React Native:** nothing changes. The npm package already includes the native frameworks, and now includes the CMP framework as well.
- **Unity:** iOS builds need the Swift package. The CocoaPods fallback was removed, so keep **Swift Package Manager Integration** on in the External Dependency Manager's iOS Resolver, or add the package in Xcode. See [Unity: iOS](/docs/mobileapps/unity/#ios).

