# Introduction

Welcome, developers! Our docs cover everything from basic integration to advanced use cases, providing examples, guides, and all the essential information you need.

## ContextSDK Basics

Learn ContextSDK basic features to kickstart your integration, and understand how the pieces connect

<table data-view="cards"><thead><tr><th></th><th></th><th data-hidden data-card-cover data-type="files"></th><th data-hidden></th><th data-hidden data-card-target data-type="content-ref"></th></tr></thead><tbody><tr><td><strong>Getting Started</strong></td><td>Integrating the SDK</td><td><a href="/files/rzKI1vlCzYkEafgRFovp">/files/rzKI1vlCzYkEafgRFovp</a></td><td></td><td><a href="/pages/CtbNS77VsyWPmRFdNotY">/pages/CtbNS77VsyWPmRFdNotY</a></td></tr><tr><td><strong>How ContextDecision works</strong></td><td>Learn the data flow of ContextDecision</td><td><a href="/files/Drlu98RYfHAtDKL79Q1g">/files/Drlu98RYfHAtDKL79Q1g</a></td><td></td><td><a href="/pages/asLZa1UAQVRS3CJHoVwk">/pages/asLZa1UAQVRS3CJHoVwk</a></td></tr><tr><td><strong>How ContextPush works</strong></td><td>Learn the data flow of ContextPush</td><td><a href="/files/5wvpagHZeBtaVtV5iiLq">/files/5wvpagHZeBtaVtV5iiLq</a></td><td></td><td><a href="/pages/ztw8woOj4GrZ7sbUtVac">/pages/ztw8woOj4GrZ7sbUtVac</a></td></tr></tbody></table>

## Technical Excellence

<table data-view="cards"><thead><tr><th></th></tr></thead><tbody><tr><td>💻 Less than <strong>0.2% CPU Usage</strong></td></tr><tr><td>🧠 <strong>0.6 MB</strong> Memory Usage</td></tr><tr><td>💾 Adds <strong>less than 500 KB</strong> to your app's download size</td></tr><tr><td>🛡️ <strong>No PII</strong> processed or stored</td></tr><tr><td>🔐 <strong>No app permissions</strong> required</td></tr><tr><td>⛓️‍💥 Operates without <strong>ATT</strong></td></tr></tbody></table>


# How ContextDecision Works

Optimize in-app offer timing to maximize conversions.

## **Overview**

ContextDecision helps you increase your app's revenue by showing the right prompts and messages at the right time. By analyzing on-device signals through Edge AI, it identifies user contexts that are best for engagement, increasing revenue and user satisfaction.

## How It Works

* ContextDecision determines the best moments to present in-app offers using real-time, on-device machine learning.
* Your app collects device signals before showing an offer and logs whether the user converts or dismisses it.
* Our team trains a machine learning model tailored to your app, using the data we collected from it. This ensures that the model performs the best for your app and your user base.
* The model suppresses offers when conversion probability is low and surfaces them at optimal moments, increasing the conversion rate.

## **Integration Overview**

1. [Integrate the SDK](/basics/getting-started) with minimal code additions — no new app permissions required.
2. [Start logging user interactions](/context-decision/logging-conversions) (successful conversions and dismissals).
3. Identify new entry points to balance the number of offers shown. To learn more, see [Adding Entry Points](/context-decision/adding-entry-points).
4. Allow time for the [**Calibration Phase**](https://docs.contextsdk.com/other/glossary#calibration-phase) (approximately 1,000 positive interactions collected).
5. After calibration, a custom model is trained and deployed automatically, optimizing offer timing in real-time.

## **Privacy Considerations**

ContextDecision prioritizes user privacy by processing data on-device, ensuring that no personally identifiable information (PII) is collected or stored. This approach maintains compliance with privacy regulations and fosters user trust.

By leveraging ContextDecision, your application can deliver in-app offers and messages at moments that align with user readiness, thereby enhancing engagement and driving revenue growth.


# How ContextPush Works

Deliver non-time-sensitive notifications at the best moment to increase open rate and maximize engagement.

## Overview

ContextPush optimizes the delivery of non-time-sensitive push notifications to maximize engagement. By leveraging machine learning, it determines the best moment within a given time window to deliver notifications when users are most likely to interact.

<figure><img src="/files/DwO9dpEl30NbUbIlQI5O" alt="" width="563"><figcaption><p>Overview of ContextPush</p></figcaption></figure>

## How It Works

* Your push notification infrastructure sends a request to ContextPush’s servers, which then manage the delivery.
  * Here the "push notification infrastructure" can be any system you already use today, such as CRM platforms like Braze, Customer.io, OneSignal, Iterable, etc., or your own custom backend.
* Background push notifications periodically wake your app for a few seconds to assess the user’s real-world context.
* During the calibration phase, notifications are sent at random times within the specified window to gather insights on user engagement patterns.
  * The duration of the calibration phase depends on the volume of push notifications your app sends. This could be as fast as just 1 day for apps that send a lot of notifications.
* Once the custom model is trained, it is deployed to your app, enabling real-time optimization of notification timing.
* From that point forward, ContextPush ensures notifications are delivered at the most opportune moment for the highest open rate.
* You define the time window in which notifications can be shown (e.g., within a 12-hour period).

## Integration Overview

Integrating ContextPush requires minimal effort and can be completed in just a few steps:

1. Provide your push notification certificate
2. Create a license key for your app and integrate the iOS SDK (less than 10 lines of code)
3. Deploy your app
4. Allow time for data collection (roughly one week). During this period, ContextPush collects push tokens as users interact with your app.
5. Modify your existing push notification logic to send requests to ContextPush’s server instead of delivering push notifications directly. This step varies depending on your current provider but typically requires only a few minutes to configure.
6. Wait for the Calibration Phase. ContextPush begins analyzing user engagement by delivering notifications at randomized times within your specified window.
7. Custom Model Deployment: once calibration is complete, your first custom machine learning model is shipped, enabling optimized push notification delivery.

## Best Use Cases

* ContextPush is ideal for non-time-sensitive notifications, such as news updates, promotional offers, and general reminders.
  * Time-sensitive notifications, such as chat messages, transactional notifications, or urgent promotions, should continue to be handled by your existing push notification provider.
* Use ContextPush to offload re-engagement campaigns and other notifications that benefit from optimized delivery timing.


# Getting Started

Get started with ContextSDK — sign up, create a license key, and integrate the SDK with step-by-step setup guides for all platforms.

## Sign Up and Create a License Key

To start using ContextSDK, sign up in our [ContextSDK Dashboard](https://dashboard.contextsdk.com/) and create a license key for your app. This key is required to authenticate your integration with ContextDecision or ContextPush.

{% hint style="info" %}
If your app has a different bundle ID or package name for different environments, register each one of them separately, as each environment will have a unique license key that should be used.
{% endhint %}

## SDK Integration

Once you have your license key, integrate ContextSDK into your app. The following sections provide step-by-step instructions for iOS, Android, Flutter, Unity, and React Native.

{% tabs %}
{% tab title="iOS" %}
**Installation**

{% tabs %}
{% tab title="Swift Package Manager (SPM)" %}
Add `https://github.com/context-sdk/context-sdk-releases.git` as a dependency in Xcode Package Dependencies or in your `Package.swift` file:

```swift
dependencies: [
    .package(url: "https://github.com/context-sdk/context-sdk-releases", .upToNextMajor(from: "5.17.0")),
]
```

{% endtab %}

{% tab title="CocoaPods" %}
Add the following dependency to your `Podfile` and run `pod install` :

{% code title="Podfile" %}

```ruby
pod "ContextSDK"
```

{% endcode %}
{% endtab %}

{% tab title="Manually" %}

1. Download the latest version of ContextSDK: [ContextSDK.zip](https://storage.googleapis.com/de73e410-context-sdk-releases/latest/ContextSDK.zip)
2. In Xcode, drag `ContextSDK.xcframework` into the Project Navigator.
3. Open your project settings, navigate to `Frameworks, Libraries, and Embedded Content`, add `ContextSDK.xcframework`, and set it to `Embed & Sign`.

To download a specific version, replace `latest` in the download URL with the desired version number. For example, to download version 5.17.0:

<https://storage.googleapis.com/de73e410-context-sdk-releases/5.17.0/ContextSDK.zip>
{% endtab %}
{% endtabs %}

**SDK Initialization**

{% tabs %}
{% tab title="UIKit" %}

<pre class="language-swift" data-title="AppDelegate.swift" data-overflow="wrap"><code class="lang-swift">import ContextSDK

class AppDelegate: NSObject, UIApplicationDelegate {
    // …
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
        // …
<strong>        ContextManager.applicationDidFinishLaunchingWithOptions(launchOptions, licenseKey: "YOUR_LICENSE_KEY")
</strong>        // …
    }
}
</code></pre>

{% endtab %}

{% tab title="SwiftUI" %}

1. Create an `AppDelegate` class if you don't have one yet:

{% code title="AppDelegate.swift" overflow="wrap" %}

```swift
import ContextSDK

class AppDelegate: NSObject, UIApplicationDelegate {
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
        ContextManager.applicationDidFinishLaunchingWithOptions(launchOptions, licenseKey: "YOUR_LICENSE_KEY")
        return true
    }
}
```

{% endcode %}

2. If you didn't have an `AppDelegate` class yet, add a `UIApplicationDelegateAdaptor` property wrapper to your `App` scene:

{% code title="YourApp.swift" %}

```swift
struct YourApp: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
    // …
}
```

{% endcode %}
{% endtab %}
{% endtabs %}
{% endtab %}

{% tab title="Android" %}
**Installation**

{% hint style="warning" %}
**Note:** Android support is currently in beta. To obtain a license key, contact us at <support@contextsdk.com>
{% endhint %}

{% tabs %}
{% tab title="Kotlin" %}
Add the ContextSDK Maven repository in your project-level `settings.gradle.kts`:

{% code title="settings.gradle.kts" %}

```java
dependencyResolutionManagement {
    repositories {
        // …
        // Add the ContextSDK Maven repo:
        maven {
            url = uri("https://storage.googleapis.com/fc4073e9-contextsdk-maven/")
        }
    }
}
```

{% endcode %}

Add the ContextSDK dependency in your module-level `build.gradle.kts`:

{% code title="build.gradle.kts" %}

```java
dependencies {
    // …
    implementation("com.contextsdk:contextsdk:1.0.0")
}
```

{% endcode %}
{% endtab %}

{% tab title="Java" %}
Add the ContextSDK Maven repository in your project-level `settings.gradle`:

<pre class="language-java" data-title="settings.gradle"><code class="lang-java"><strong>dependencyResolutionManagement {
</strong>    repositories {
        // …
        // Add the ContextSDK maven repo:
        maven {
            url 'https://storage.googleapis.com/fc4073e9-contextsdk-maven/'
        }
    }
}
</code></pre>

Add the ContextSDK dependency in your module-level `build.gradle`:

{% code title="build.gradle" %}

```java
dependencies {
    // …
    implementation 'com.contextsdk:contextsdk:1.0.0'
}
```

{% endcode %}
{% endtab %}
{% endtabs %}

**SDK Initialization**

{% tabs %}
{% tab title="Kotlin" %}
In your `Application` subclass, initialize ContextSDK by calling `setup` with your license key:

```kotlin
class MainApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        ContextSDK.setup(this, "YOUR_LICENSE_KEY")
    }
}
```

In your primary `Activity` subclass, or in every `Activity` in your application, allow ContextSDK to attach and detach:

```kotlin
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        ContextSDK.attachToActivity(this)
    }

    override fun onDestroy() {
        super.onDestroy()
        ContextSDK.detachFromActivity(this)
    }
}
```

{% endtab %}

{% tab title="Java" %}
In your `Application` subclass, initialize ContextSDK by calling `setup` with your license key:

```java
public class MainApplication extends Application {
    @Override
    public void onCreate() {
        super.onCreate();
        ContextSDK.Companion.setup(this, "YOUR_LICENSE_KEY", new ContextSDKConfiguration());
    }
}
```

2. In your primary `Activity` subclass, or in every `Activity` in your application, allow ContextSDK to attach and detach:

```java
public class MainActivity extends AppCompatActivity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        ContextSDK.Companion.attachToActivity(this);
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        ContextSDK.Companion.detachFromActivity(this);
    }
}
```

{% endtab %}
{% endtabs %}

{% hint style="warning" %}
The `Activity` must conform to `LifecycleOwner`, such as `AppCompatActivity`. Standard `Activity` subclasses are not supported.

If ContextSDK is not set up correctly, it will be unable to start or stop collecting accelerometer and gyroscope data.
{% endhint %}
{% endtab %}

{% tab title="Flutter" %}
**Installation**

Add `context_sdk` to your `pubspec.yaml`:

{% code title="pubspec.yaml" %}

```yaml
dependencies:
  context_sdk: ^latest_version
```

{% endcode %}

Ensure your project meets the [Minimum SDK Requirements](/other/minimum-sdk-requirements) target by updating your `ios/Podfile`, e.g.:

{% code title="ios/Podfile" %}

```ruby
platform :ios, "15.0"
```

{% endcode %}

**SDK Initialization**

Add the following code to your app’s launch:

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

final _contextSdkPlugin = ContextSdk();

_contextSdkPlugin.setup("YOUR_LICENSE_KEY");
```

{% endtab %}

{% tab title="Unity" %}
**Installation**

1. Download the latest version of ContextSDK: [ContextSDK.zip](https://storage.googleapis.com/de73e410-context-sdk-releases/latest/ContextSDK.zip).
2. In your Unity project, drag `ContextSDK.xcframework` into the `Assets/Plugins/iOS` folder.
3. Add the `ContextSDKBinding.cs` script in the same folder.

To download a specific version, replace `latest` in the download URL with the desired version number. For example, to download version 5.17.0:

<https://storage.googleapis.com/de73e410-context-sdk-releases/5.17.0/ContextSDK.zip>

**SDK Initialization**

Set up the `ContextManager` in the `Start()` method of a `MonoBehaviour` that runs early in your game, before accessing the SDK:

```csharp
using static ContextSDKBinding;

public class InitContextSDK : MonoBehaviour
{
    void Start()
    {
        ContextSDKBinding.SetupWithAPIBackend("YOUR_LICENSE_KEY");
    }
}
```

{% endtab %}

{% tab title="React Native" %}
**Installation**

Install ContextSDK using your preferred package manager:

```sh
npm install react-native-context-sdk@latest
```

or

```sh
yarn add react-native-context-sdk
```

Ensure your project meets the [Minimum SDK Requirements](/other/minimum-sdk-requirements) target by updating your `ios/Podfile`, e.g.:

{% code title="ios/Podfile" %}

```ruby
platform :ios, "15.0"
```

{% endcode %}

**SDK Initialization**

Add the following code to your app’s launch:

```js
import { setup } from "react-native-context-sdk";

void setup("YOUR_LICENSE_KEY");
```

{% endtab %}
{% endtabs %}

## ContextDecision

ContextDecision optimizes conversion funnels by intelligently determining the best moments to present monetization or engagement opportunities. Now that you’ve integrated ContextSDK, to get started with ContextDecision, see [Logging Conversions](/context-decision/logging-conversions).

## ContextPush

ContextPush improves the timing of non-transactional push notifications by leveraging user's real-life context. To begin integrating ContextPush, see [Set Up Client Integration](/context-push/integration).


# Logging Conversions

Learn how to capture user context and log conversions to optimize upsell decisions.

Optimizing conversions begins with tracking them. In ContextDecision, this is done by capturing the user’s context and logging whether a conversion occurred.

Our SDK provides different methods for capturing context, each designed for specific use cases. This document outlines these methods and explains when to use them.

{% hint style="info" %}
If you’re new here, refer to the instructions at [Getting Started](/basics/getting-started) for guidance on generating your license key and installing the SDK.
{% endhint %}

## Capturing Context

{% tabs %}
{% tab title="iOS" %}
{% code title="ContextManager.swift" %}

```swift
static func fetchContext(
    flowName: String,
    duration: Int,
    customSignals: [CustomSignal] = [],
    callback: @escaping ((Context) -> Void)
)
```

{% endcode %}
{% endtab %}

{% tab title="Android" %}
{% tabs %}
{% tab title="Kotlin" %}
{% code title="ContextSDK.kt" %}

```kotlin
fun fetchContext(
    flowName: String,
    durationS: Int = 3,
    customSignals: CustomSignals = CustomSignals(),
    callback: (RealWorldContext) -> Unit
)
```

{% endcode %}
{% endtab %}

{% tab title="Java" %}
{% code title="ContextSDK.java" %}

```java
public void fetchContext(
    String flowName,
    int durationS,
    CustomSignals customSignals,
    Consumer<RealWorldContext> callback
)
```

{% endcode %}
{% endtab %}
{% endtabs %}
{% endtab %}

{% tab title="Flutter" %}
{% code title="context\_sdk\_platform\_interface.dart" %}

```dart
Future<int> fetchContext(
  String flowName,
  int duration,
  Map<String, dynamic>? customSignals,
)
```

{% endcode %}
{% endtab %}

{% tab title="Unity" %}
{% code title="ContextSDKBinding.cs" %}

```cs
public static void FetchContext(
    string flowName,
    ContextDelegate callback,
    CustomSignals? customSignals = null,
    int duration = 3
)
```

{% endcode %}
{% endtab %}

{% tab title="React Native" %}
{% code title="index.tsx" %}

```javascript
export function fetchContext(options: {
  flowName: string;
  onContextReady: (context: Context) => void;
  duration?: number;
  customSignals?: CustomSignals;
}): void
```

{% endcode %}
{% endtab %}
{% endtabs %}

This method allows you to capture the user context asynchronously. This is the recommended method to be used in most scenarios.

{% hint style="warning" %}
Always call this method, and other context-capturing methods, **before** displaying an in-app offer. This is because if the context is captured **after** showing the offer, not only ContextDecision can't make a decision on whether to show the offer or not (because it's already been shown), but also the user's context likely will have already changed (e.g. user was lying in bed but once they see an upsell offer they get up or leave the app).
{% endhint %}

When calling this method, you provide:

* A flow name, which uniquely identifies the context being captured. Each flow should have a distinct name, even if the same flow is triggered from different parts of the app. We automatically analyze the overlap between different flows, to decide if there should be one, or more models.
  * **Best practice**: use `snake_case` and group flows that lead to the same prompt using the same prefix, e.g. `upsell_onboarding`, `upsell_first_action` .
* A duration, which determines how long accelerometer and gyroscope data is collected, in seconds. This value must be between 2 and 7 seconds, with a recommended default of 3 seconds.
* An optional array of custom signals, which allows you to append additional contextual information relevant to your flow or app. Learn more in [Custom Signals](/context-decision/advanced/custom-signals).

Once the context is ready, the callback executes asynchronously on the main thread. The execution timing depends on your app’s state:

* If the app has been active and in the foreground for at least 3 seconds (or your configured `duration`), the callback executes instantly.
* If the app was recently launched or resumed from the background, it may take **up to** 3 seconds (or your configured `duration`) for the context to be available.

### Logging Outcomes

An outcome indicates whether an offer led to a conversion (e.g., a purchase, an ad click) or was dismissed. Always log at least one outcome for each captured context to help train the ML models effectively.

For in-app purchases, we require the product that was purchased to be passed as an argument to the outcome log. See [Revenue Outcomes](/context-decision/revenue-outcomes) for more details.

If your offer is a paywall with a "Restore Purchases" button, see [Alternative Outcomes](/context-decision/advanced/alternative-outcomes#the-skipped-outcome).

{% hint style="success" %}
For advanced outcome options, see [Alternative Outcomes](/context-decision/advanced/alternative-outcomes) and [Custom Outcome Metadata](/context-decision/advanced/custom-outcome-metadata).
{% endhint %}

### Decision-Making

The `context` object returned by `fetchContext` includes a `shouldUpsell` property, which determines whether an upsell offer should be shown. During the [calibration phase](https://docs.contextsdk.com/other/glossary#calibration-phase), this property always returns `true`. Once a ML model is deployed to the flow, it starts making real-time decisions. If the model determines that it's a bad time, `shouldUpsell` will be `false`, so you can not show the paywall, and thus log `skipped` as the outcome.

{% hint style="info" %}
There are exceptions, such as the [Multivariate Monetization](/use-cases/multivariate-monetization) use case, where you would show an ad (not a paywall) when `shouldUpsell` is `false`. In these cases, you shouldn't log the `skipped` outcome, but instead log the outcome of your ad interaction.

For more details about this use case, see [Multivariate Monetization](/use-cases/multivariate-monetization).
{% endhint %}

### Usage Example

To use this method, show the upsell offer inside the callback block to ensure the context is evaluated before presenting the offer. This guarantees that the decision logic runs first, preventing unnecessary offers from being shown when the conditions are not met:

{% tabs %}
{% tab title="iOS" %}
{% code title="MyOnboardingViewController.swift" %}

```
// 1. Capture the context
ContextManager.fetchContext(flowName: "upsell_onboarding") { [weak self] context in
    // 2. Check if shouldUpsell is true
    guard context.shouldUpsell else {
        context.log(.skipped) // Log skipped when shouldUpsell is false
        return
    }
    // 3. Create the offer view controller
    let vc = MyPremiumOfferViewController()
    vc.userDidPurchase = { product in
        context.logRevenueOutcome(from: product)
    }
    vc.userDidDismiss = {
        context.log(.negative)
    }
    // 4. Show offer view controller here
    // …
}
```

{% endcode %}
{% endtab %}

{% tab title="Android" %}
{% tabs %}
{% tab title="Kotlin" %}
{% code title="MyOnboardingFragment.kt" %}

```kotlin
// 1. Capture the context
ContextSDK.fetchContext("upsell_onboarding") { context ->
    // 2. Check if shouldUpsell is true
    if (!context.shouldUpsell) {
        context.log(EventOutcome.SKIPPED) // Log skipped when shouldUpsell is false
        return@fetchContext
    }
    // 3. Create the offer fragment
    val fragment = MyPremiumOfferFragment().apply {
        setOnUserDidPurchaseListener { product ->
            context.log(EventOutcome.POSITIVE)
        }
        setOnUserDidDismissListener {
            context.log(EventOutcome.NEGATIVE)
        }
    }
    // 4. Show offer fragment here
    // …
}
```

{% endcode %}
{% endtab %}

{% tab title="Java" %}
{% code title="MyOnboardingFragment.java" %}

```java
// 1. Capture the context
ContextSDK.Companion.fetchContext("upsell_onboarding", null, null, context -> {
    // 2. Check if shouldUpsell is true
    if (!context.getShouldUpsell()) {
        context.log(EventOutcome.SKIPPED); // Log skipped when shouldUpsell is false
        return;
    }
    // 3. Create the offer fragment
    MyPremiumOfferFragment fragment = new MyPremiumOfferFragment();
    fragment.setOnUserDidPurchaseListener(product -> context.log(EventOutcome.POSITIVE));
    fragment.setOnUserDidDismissListener(() -> context.log(EventOutcome.NEGATIVE));
    // 4. Show offer fragment here
    // …
    return Unit.INSTANCE;
});
```

{% endcode %}
{% endtab %}
{% endtabs %}
{% endtab %}

{% tab title="Flutter" %}
{% code title="my\_onboarding\_screen.dart" %}

```dart
// 1. Capture the context
_contextSdkPlugin.fetchContext("upsell_onboarding", 3).then((context) async {
  // 2. Check if shouldUpsell is true
  if (!(await context.shouldUpsell)) {
      await context.log(Outcome.skipped); // Log skipped when shouldUpsell is false
      return;
  }
  // 3. Create the offer screen
  final offerScreen = PremiumOfferScreen(
    onPurchase: (product) {
      await context.log(Outcome.positive);
    },
    onDismiss: () {
      await context.log(Outcome.negative);
    },
  );
  // 4. Show offer screen here
  // …
});
```

{% endcode %}
{% endtab %}

{% tab title="Unity" %}
{% code title="MyOnboardingScreen.cs" %}

```cs
// 1. Capture the context
ContextSDKBinding.FetchContext("upsell_onboarding", delegate (Context context) {
    // 2. Check if shouldUpsell is true
    if (!context.shouldUpsell) {
        context.Log(Outcome.Skipped); // Log skipped when shouldUpsell is false
        return;
    }
    // 3. Create the offer screen
    var offerScreen = new PremiumOfferScreen();
    offerScreen.OnPurchase = product =>
    {
        context.Log(Outcome.Positive);
    };
    offerScreen.OnDismiss = () =>
    {
        context.Log(Outcome.Negative);
    };
    // 4. Show offer screen here
    // …
});
```

{% endcode %}
{% endtab %}

{% tab title="React Native" %}
{% code title="MyOnboardingScreen.js" %}

```javascript
import { fetchContext, Outcome } from 'react-native-context-sdk';

// 1. Capture the context
fetchContext({
  flowName: 'upsell_onboarding',
  onContextReady: async (context) => {
    // 2. Check if shouldUpsell is true
    if (!(await context.shouldUpsell())) {
        await context.log(Outcome.skipped); // Log skipped when shouldUpsell is false
        return;
    }
    // 3. Create and navigate to the offer screen
    navigation.navigate('PremiumOffer', {
      onPurchase: (product: any) => {
        context.log(Outcome.positive);
      },
      onDismiss: () => {
        context.log(Outcome.negative);
      },
    });
  },
});
```

{% endcode %}
{% endtab %}
{% endtabs %}

If your project does not favor closure-based implementations like shown above when logging outcomes, see [#retrieving-an-existing-context](#retrieving-an-existing-context "mention") below for an alternative approach that better fits different architectures.

## Instant Context

In some scenarios, you may need to obtain a context immediately without waiting for the asynchronous callback. This is particularly useful when the flow is triggered by direct user interaction, such as tapping a button, where introducing any delay would negatively impact the user experience.

{% tabs %}
{% tab title="iOS" %}
{% code title="ContextManager.swift" %}

```swift
@discardableResult static func instantContext(
    flowName: String,
    duration: Int,
    customSignals: [CustomSignal] = []
) -> Context
```

{% endcode %}
{% endtab %}

{% tab title="Android" %}
{% tabs %}
{% tab title="Kotlin" %}
{% code title="ContextSDK.kt" %}

```kotlin
fun instantContext(
    flowName: String,
    durationS: Int = 3,
    customSignals: CustomSignals = CustomSignals()
): RealWorldContext
```

{% endcode %}
{% endtab %}

{% tab title="Java" %}
{% code title="ContextSDK.java" %}

```java
public static RealWorldContext instantContext(
    String flowName,
    int durationS,
    CustomSignals customSignals
)
```

{% endcode %}
{% endtab %}
{% endtabs %}
{% endtab %}

{% tab title="Unity" %}
{% code title="ContextSDKBinding.cs" %}

```cs
public static Context InstantContext(
    string flowName,
    CustomSignals? customSignals = null,
    int duration = 3
)
```

{% endcode %}
{% endtab %}
{% endtabs %}

Unlike `fetchContext`, this method returns immediately with a context object. The signals captured in this context are based on data the SDK has already collected in the background.

{% hint style="warning" %}
**Do not use instant context for flows that occur immediately after app launch.** Since the SDK needs time to collect accelerometer and gyroscope data, an instant context created too early will not contain valid motion signals. This results in lower-quality data for model training and less accurate predictions.

Use `fetchContext` instead for flows that might trigger shortly after launch, as it will wait until sufficient data is collected.
{% endhint %}

{% hint style="info" %}
Instant context is ideal for user-initiated actions like button taps that happen well after the app has been active. In these cases, the SDK has typically been running long enough to have collected the necessary data.
{% endhint %}

### Usage Example

{% tabs %}
{% tab title="iOS" %}
{% code title="MySettingsViewController.swift" %}

```swift
@IBAction func upgradeTapped(_ sender: UIButton) {
    // 1. Capture the context instantly (user tapped a button)
    let context = ContextManager.instantContext(flowName: "upsell_settings_upgrade")

    // 2. Check if shouldUpsell is true
    guard context.shouldUpsell else {
        context.log(.skipped)
        return
    }

    // 3. Create the offer view controller
    let vc = MyPremiumOfferViewController()
    vc.userDidPurchase = { product in
        context.logRevenueOutcome(from: product)
    }
    vc.userDidDismiss = {
        context.log(.negative)
    }

    // 4. Show offer view controller
    present(vc, animated: true)
}
```

{% endcode %}
{% endtab %}

{% tab title="Android" %}
{% tabs %}
{% tab title="Kotlin" %}
{% code title="MySettingsFragment.kt" %}

```kotlin
upgradeButton.setOnClickListener {
    // 1. Capture the context instantly (user tapped a button)
    val context = ContextSDK.instantContext("upsell_settings_upgrade")

    // 2. Check if shouldUpsell is true
    if (!context.shouldUpsell) {
        context.log(EventOutcome.SKIPPED)
        return@setOnClickListener
    }

    // 3. Create the offer fragment
    val fragment = MyPremiumOfferFragment().apply {
        setOnUserDidPurchaseListener { product ->
            context.log(EventOutcome.POSITIVE)
        }
        setOnUserDidDismissListener {
            context.log(EventOutcome.NEGATIVE)
        }
    }

    // 4. Show offer fragment
    // ...
}
```

{% endcode %}
{% endtab %}

{% tab title="Java" %}
{% code title="MySettingsFragment.java" %}

```java
upgradeButton.setOnClickListener(v -> {
    // 1. Capture the context instantly (user tapped a button)
    RealWorldContext context = ContextSDK.instantContext("upsell_settings_upgrade", 3, new CustomSignals());

    // 2. Check if shouldUpsell is true
    if (!context.getShouldUpsell()) {
        context.log(EventOutcome.SKIPPED);
        return;
    }

    // 3. Create the offer fragment
    MyPremiumOfferFragment fragment = new MyPremiumOfferFragment();
    fragment.setOnUserDidPurchaseListener(product -> context.log(EventOutcome.POSITIVE));
    fragment.setOnUserDidDismissListener(() -> context.log(EventOutcome.NEGATIVE));

    // 4. Show offer fragment
    // ...
});
```

{% endcode %}
{% endtab %}
{% endtabs %}
{% endtab %}

{% tab title="Unity" %}
{% code title="MySettingsScreen.cs" %}

```cs
public void OnUpgradeButtonClicked()
{
    // 1. Capture the context instantly (user tapped a button)
    var context = ContextSDKBinding.InstantContext("upsell_settings_upgrade");

    // 2. Check if shouldUpsell is true
    if (!context.shouldUpsell)
    {
        context.Log(Outcome.Skipped);
        return;
    }

    // 3. Create the offer screen
    var offerScreen = new PremiumOfferScreen();
    offerScreen.OnPurchase = product =>
    {
        context.Log(Outcome.Positive);
    };
    offerScreen.OnDismiss = () =>
    {
        context.Log(Outcome.Negative);
    };

    // 4. Show offer screen
    // ...
}
```

{% endcode %}
{% endtab %}
{% endtabs %}

## Retrieving an Existing Context

Not all architectures use closures for inter-view-controller communication. In such cases, you can capture the context and present the offer in one place, and log the outcome separately. Use the `recentContext(flowName:)` method to retrieve a previously captured context for a given flow name.

{% hint style="info" %}
Note: This method does not capture a new context — it only retrieves an existing one. If no context has been captured for the specified flow, this method returns `nil`.
{% endhint %}

### Usage Example

{% tabs %}
{% tab title="iOS" %}
{% code title="MyOnboardingViewController.swift" %}

```
ContextManager.fetchContext(flowName: "upsell_onboarding") { [weak self] context in
    guard context.shouldUpsell else {
        context.log(.skipped)
        return
    }
    self?.present(MyPremiumOfferViewController(), animated: true)
}
```

{% endcode %}

{% code title="MyPremiumOfferViewController.swift" %}

```
func userDidCompletePurchase(product: Product) {
    if let context = ContextManager.recentContext(flowName: "upsell_onboarding") {
        context.logRevenueOutcome(from: product)
    } else {
        // This is an error state. Make sure the context above is created first.
    }
}

func dismissViewController() {
    if let context = ContextManager.recentContext(flowName: "upsell_onboarding") {
        context.log(.negative)
    } else {
        // This is an error state. Make sure the context above is created first.
    }
    // Dismiss your view controller here
}
```

{% endcode %}
{% endtab %}

{% tab title="Android" %}
{% tabs %}
{% tab title="Kotlin" %}
{% code title="MyOnboardingFragment.kt" %}

```kotlin
ContextSDK.fetchContext("upsell_onboarding") { context ->
    if (!context.shouldUpsell) {
        context.log(EventOutcome.SKIPPED)
        return@fetchContext
    }
    val fragment = MyPremiumOfferFragment()
    // Show fragment here
}
```

{% endcode %}

{% code title="MyPremiumOfferFragment.kt" %}

```kotlin
fun userDidCompletePurchase(product: Product) {
    val context = ContextSDK.recentContext("upsell_onboarding")
    if (context != null) {
        context.log(EventOutcome.POSITIVE)
    } else {
        // This is an error state. Make sure the context above is created first.
    }
}

fun dismissFragment(fragment: Fragment) {
    val context = ContextSDK.recentContext("upsell_onboarding")
    if (context != null) {
        context.log(EventOutcome.NEGATIVE)
    } else {
        // This is an error state. Make sure the context above is created first.
    }
    // Dismiss the fragment here
}
```

{% endcode %}
{% endtab %}

{% tab title="Java" %}
{% code title="MyOnboardingFragment.java" %}

```java
ContextSDK.Companion.fetchContext("upsell_onboarding", null, null, context -> {
    if (!context.getShouldUpsell()) {
        context.log(EventOutcome.SKIPPED);
        return;
    }
    MyPremiumOfferFragment fragment = new MyPremiumOfferFragment();
    // Show fragment here
    return Unit.INSTANCE;
});
```

{% endcode %}

{% code title="MyPremiumOfferFragment.java" %}

```java
public void userDidCompletePurchase(Product product) {
    RealWorldContext context = ContextSDK.Companion.recentContext("upsell_onboarding");
    if (context != null) {
        context.log(EventOutcome.POSITIVE);
    } else {
        // This is an error state. Make sure the context above is created first.
    }
}

public void dismissFragment(Fragment fragment) {
    RealWorldContext context = ContextSDK.Companion.recentContext("upsell_onboarding");
    if (context != null) {
        context.log(EventOutcome.NEGATIVE);
    } else {
        // This is an error state. Make sure the context above is created first.
    }
    // Dismiss the fragment here
}
```

{% endcode %}
{% endtab %}
{% endtabs %}
{% endtab %}

{% tab title="Flutter" %}
{% code title="my\_onboarding\_screen.dart" %}

```dart
_contextSdkPlugin.fetchContext("upsell_onboarding", 3).then((context) async {
  if (!(await context.shouldUpsell)) {
      await context.log(Outcome.skipped);
      return;
  }
  // Show offer screen here
  // …
});
```

{% endcode %}

{% code title="my\_premium\_offer\_screen.dart" %}

```dart
void userDidCompletePurchase(Product product) async {
  final context = await _contextSdkPlugin.recentContext("upsell_onboarding");
  if (context != null) {
    await context.log(Outcome.positive);
  } else {
    // This is an error state. Make sure the context above is created first.
  }
}

void dismissView(BuildContext context) async {
  final context = await _contextSdkPlugin.recentContext("upsell_onboarding");
  if (context != null) {
    await context.log(Outcome.negative);
  } else {
    // This is an error state. Make sure the context above is created first.
  }
  // Dismiss screen here
}
```

{% endcode %}
{% endtab %}

{% tab title="Unity" %}
{% code title="MyOnboardingScreen.cs" %}

```cs
public void UserDidCompletePurchase(Product product)
{
    var context = ContextSDKBinding.RecentContext("upsell_onboarding");
    if (context != null)
    {
        context.Log(Outcome.Positive);
    }
    else
    {
        // This is an error state. Make sure the context above is created first.
    }
}

public void DismissView()
{
    var context = ContextSDKBinding.RecentContext("upsell_onboarding");
    if (context != null)
    {
        context.Log(Outcome.Negative);
    }
    else
    {
        // This is an error state. Make sure the context above is created first.
    }

    // Dismiss screen here
}
```

{% endcode %}
{% endtab %}
{% endtabs %}


# Revenue Outcomes

Learn when and how to log revenue outcomes in ContextSDK to optimize monetization and improve model accuracy.

{% hint style="warning" %}
This API is only available in iOS at the moment. Please reach out to our team if you need this API in Android.
{% endhint %}

## What is a revenue outcome?

A revenue outcome represents the result of a monetization event in your app, such as an in-app purchase or an ad impression.

## Why is it important?

Understanding how and when users contribute to revenue allows ContextSDK to make informed predictions about the best moments to display monetization opportunities. Logging revenue outcomes helps ContextSDK optimize your ML models, ensuring that monetization decisions align with real-world user behavior, so we can optimize your app for revenue generation.

## When should I log revenue outcomes?

Log a revenue outcome whenever the user generates revenue. This includes in-app purchases, ad clicks, ad impressions, tips, or other sources of revenue.

Use revenue outcomes only for events that directly lead to revenue. For non-revenue actions that are positive, like permission prompts or rating requests, use standalone positive outcomes instead of revenue outcomes.

**When NOT to log revenue outcomes:**

* If the user dismisses the offer or doesn't convert, log a standalone negative outcome, not a revenue outcome
* If a purchase transaction fails due to technical issues (network problems, payment method issues, etc.), don't log anything - these failures don't reflect user intent or context quality

## How to log revenue outcomes

Here's a few examples of how to log revenue outcomes for different scenarios.

### In-app purchases

#### StoreKit 2 (iOS 15+)

If you're using **StoreKit 2**, log a purchase like this:

```swift
// `product` is the StoreKit 2 product that was purchased.
context.logRevenueOutcome(from: product)
```

#### StoreKit 1 (iOS 8+)

If your app uses **StoreKit 1** or needs to support iOS 14 and earlier, see [StoreKit1 Docs](/context-decision/revenue-outcomes/logging-revenue-with-storekit-1).

#### Third-party frameworks

If you're using **Superwall**, see [Using ContextSDK with Superwall](/context-decision/revenue-outcomes/superwall)

If you're using **Purchasely**, see [Using ContextSDK with Purchasely](/context-decision/revenue-outcomes/purchasely)

If you're using **RevenueCat**, see [Logging Revenue with RevenueCat](/context-decision/revenue-outcomes/revenuecat)

If you're using a different in-app purchase framework, retrieve the equivalent StoreKit product object and pass it to `logRevenueOutcome(from:)` for StoreKit 2 or `logStoreKit1RevenueOutcome(from:)` for StoreKit 1.

{% hint style="success" %}
There's no need to call `context.log(.positive)` after logging a revenue outcome from a `Product`, as the positive outcome is already logged internally. Alternatively, you can customize the outcome to be logged directly, via `logRevenueOutcome(from:outcome:)`.

For a list of all available outcomes see [Alternative Outcomes](/context-decision/advanced/alternative-outcomes).
{% endhint %}

### Ad clicks and impressions

For an **ad click**, log the revenue outcome as follows:

```swift
let event = RevenueEvent(source: .adClick, revenue: 0.09, currency: .usd)
context.logRevenueOutcome(from: event, outcome: .positiveAdTapped)
```

Or for an **ad impression**:

```swift
let event = RevenueEvent(source: .adImpression, revenue: 0.00175, currency: .usd)
context.logRevenueOutcome(from: event, outcome: .negativeNotInteracted)
```

The revenue values in these examples are placeholders. Use the actual revenue values from your ad network's SDK, preferring the most precise values available. If exact values aren't provided, an estimate is acceptable, however, greater precision improves accuracy.

Some ad networks may provide callbacks that contain live revenue data. Use this data to log revenue outcomes in real-time, ensuring that the revenue data is as accurate as possible.

## Best practices

* **Avoid duplicate logging:** Use `logRevenueOutcomeIfNotLoggedYet(from:)` for events where duplicate logging may occur.


# Using ContextSDK with Superwall

Learn how to use ContextSDK with Superwall to optimize paywall timing and log conversion revenue from in-app purchases.

ContextSDK integrates seamlessly with Superwall to help you show paywalls at the perfect moment. This integration leverages ContextSDK's ML-powered recommendations to optimize when Superwall displays paywalls, improving conversion rates.

## How it works

ContextSDK provides real-world context about whether it's a good moment to show a paywall through the **`shouldUpsell`** property - a boolean indicating whether it's a good moment to show a paywall.

This value is passed to Superwall as a parameter (`contextGoodMoment`), allowing you to:

* Use it in paywall display rules
* Track it in your analytics
* A/B test different timing strategies

## Integration Guide

### Step 1: Capture Context and Show Paywall

Before showing a paywall, capture the user's context using `instantContext` and pass it to Superwall:

```swift
import ContextSDK
import SuperwallKit

class OnboardingViewController: UIViewController {

    func showPaywall() {
        // 1. Capture the user's context
        let context = ContextManager.instantContext(flowName: "superwall_onboarding_step", duration: 3)

        // 2. Create handler for paywall callbacks
        let handler = PaywallPresentationHandler()
        handler.onDismiss { paywallInfo, result in
            switch result {
            case .purchased(let product):
                // User completed a purchase
                context.logRevenueOutcome(from: product)

            case .declined:
                // User declined the paywall by pressing close button
                context.log(.negative)

            case .restored:
                // User restored their purchases
                context.log(.skipped)

            default:
                context.log(.skipped)
            }
        }
        handler.onSkip { reason in
            // Paywall was skipped (no paywall configured, user already subscribed, etc.)
            context.log(.skipped)
        }

        // 3. Register Superwall placement with context
        Superwall.shared.register(
            placement: "onboarding_step",
            params: ["contextGoodMoment": context.shouldUpsell],
            handler: handler
        )
    }
}
```

### Step 2: Use Context in Superwall Rules

You can use the context parameter in Superwall's display rules to control when paywalls appear:

**In Superwall Dashboard:**

1. Go to your paywall configuration
2. Add display rules using the parameter:
   * `contextGoodMoment` (Boolean)

**Example rule:**

* Show paywall only when `contextGoodMoment` is `true`

This gives you the flexibility to A/B test different strategies and adjust timing logic without code changes.

## Best Practices

### Choose a Flow Name

Select a descriptive flow name that represents your use case. Use a consistent naming pattern with the `superwall_` prefix:

```swift
// Examples of good flow names:
ContextManager.instantContext(flowName: "superwall_onboarding_step", duration: 3)
ContextManager.instantContext(flowName: "superwall_premium_upgrade", duration: 3)
ContextManager.instantContext(flowName: "superwall_feature_unlock", duration: 3)
```

### Always Log Outcomes

**Critical:** Always log an outcome for every context you create. This data trains the ML model:

```swift
let handler = PaywallPresentationHandler()

// Log outcomes in onDismiss
handler.onDismiss { paywallInfo, result in
    switch result {
    case .purchased(let product):
        context.logRevenueOutcome(from: product)
    case .declined:
        context.log(.negative)
    case .restored:
        context.log(.skipped)
    default:
        context.log(.skipped)
    }
}

// Always log skipped when paywall doesn't show
handler.onSkip { reason in
    context.log(.skipped)
}
```

### Handle Multiple Placements

You can use this pattern across multiple placements in your app:

```swift
func showOnboardingPaywall() {
    let context = ContextManager.instantContext(flowName: "superwall_onboarding", duration: 3)
    showSuperwall(placement: "onboarding_step", context: context)
}

func showSettingsPaywall() {
    let context = ContextManager.instantContext(flowName: "superwall_settings", duration: 3)
    showSuperwall(placement: "settings", context: context)
}

private func showSuperwall(placement: String, context: Context) {
    let handler = PaywallPresentationHandler()
    handler.onDismiss { paywallInfo, result in
        switch result {
        case .purchased(let product):
            context.logRevenueOutcome(from: product)
        case .declined:
            context.log(.negative)
        case .restored:
            context.log(.skipped)
        default:
            context.log(.skipped)
        }
    }
    handler.onSkip { reason in
        context.log(.skipped)
    }

    Superwall.shared.register(
        placement: placement,
        params: ["contextGoodMoment": context.shouldUpsell],
        handler: handler
    )
}
```

## Related Documentation

* [Revenue Outcomes](/context-decision/revenue-outcomes) - Learn more about logging revenue outcomes
* [Logging Conversions](/context-decision/logging-conversions) - General guide to logging outcomes
* [Custom Outcome Metadata](/context-decision/advanced/custom-outcome-metadata) - Track additional purchase context

{% hint style="success" %}
ContextSDK's ML models learn from your outcome data. The more purchases you log with `logRevenueOutcome()`, the better the model becomes at predicting optimal moments to show paywalls.
{% endhint %}


# Using ContextSDK with Purchasely

Learn how to use ContextSDK with Purchasely to optimize paywall timing and log conversion revenue from in-app purchases.

ContextSDK integrates seamlessly with Purchasely to help you show paywalls at the perfect moment. This integration leverages ContextSDK's ML-powered recommendations to optimize when Purchasely displays paywalls, improving conversion rates.

## How it works

ContextSDK provides real-world context about whether it's a good moment to show a paywall through the **`shouldUpsell`** property - a boolean indicating whether it's a good moment to show a paywall.

This value is passed to Purchasely as a user attribute (`context_should_upsell`), allowing you to:

* Use it in paywall display rules and audience targeting
* Track context in your analytics
* A/B test different timing strategies
* Reduce interruptions during bad moments and maximize conversions

## Integration Guide

### Step 1: Capture Context and Show Paywall

Before showing a paywall, capture the user's context using `instantContext` and pass it to Purchasely as a user attribute:

```swift
import ContextSDK
import Purchasely

class OnboardingViewController: UIViewController {

    func showPaywall() {
        // 1. Capture the user's real-world context
        let context = ContextManager.instantContext(flowName: "purchasely_onboarding", duration: 3)

        // 2. Set the context as a Purchasely user attribute
        Purchasely.setUserAttribute(withBoolValue: context.shouldUpsell, forKey: "context_should_upsell")

        // 3. Load and show the Purchasely paywall
        let paywallController = Purchasely.presentationController(
            for: "onboarding",
            loaded: { [weak self] controller, success, error in
                if let controller = controller, success {
                    self?.present(controller, animated: true)
                } else if let error = error {
                    print("Failed to load paywall: \(error)")
                    context.log(.skipped)
                }
            },
            completion: { result, _ in
                // 4. Log the outcome to help train the ML model
                switch result {
                case .purchased:
                    context.log(.positive)
                case .cancelled:
                    context.log(.negative)
                case .restored:
                    context.log(.skipped)
                @unknown default:
                    context.log(.skipped)
                }
            }
        )
    }
}
```

### Step 2: Configure Purchasely Rules

You can use the context attribute in Purchasely's audience rules to control when paywalls appear:

**In Purchasely Console:**

1. Go to your placement configuration
2. Add audience rules using `context_should_upsell` (Boolean)
3. For example: Only show the paywall when `context_should_upsell` is `true`

This gives you the flexibility to A/B test different strategies and adjust timing logic without code changes.

### Understanding the Flow

Let's break down what's happening:

1. **`ContextManager.instantContext(flowName:duration:)`** - This captures the user's real-world context synchronously. The flow name (e.g., `"purchasely_onboarding"`) uniquely identifies this opportunity in your app. Use descriptive names like `"purchasely_settings"`, `"purchasely_post_action"`, etc.
2. **`Purchasely.setUserAttribute()`** - This passes the `shouldUpsell` value to Purchasely as a user attribute. During the initial calibration phase, this value is always `true`. Once your custom ML model is trained and deployed, it makes real-time decisions based on the user's context.
3. **`Purchasely.presentationController(for:)`** - This is your standard Purchasely integration. The placement ID (e.g., `"onboarding"`) should match what you've configured in your Purchasely dashboard.
4. **`context.log()`** - This logs the outcome, which is crucial for training the ML model:
   * `.positive` - User completed a purchase
   * `.negative` - User dismissed the paywall
   * `.skipped` - Paywall wasn't shown, or user restored purchases

## Logging Revenue Outcomes

For in-app purchases, you can optionally log revenue information to get more detailed analytics. Update your code to use `logRevenueOutcome`:

```swift
let context = ContextManager.instantContext(flowName: "purchasely_onboarding", duration: 3)

// Set the context as a Purchasely user attribute
Purchasely.setUserAttribute(withBoolValue: context.shouldUpsell, forKey: "context_should_upsell")

let paywallController = Purchasely.presentationController(
    for: "onboarding",
    loaded: { [weak self] controller, success, error in
        if let controller = controller, success {
            self?.present(controller, animated: true)
        } else if let error = error {
            print("Failed to load paywall: \(error)")
            context.log(.skipped)
        }
    },
    completion: { result, plan in
        switch result {
        case .purchased:
            // Log revenue with product details
            if let plan = plan {
                context.logRevenueOutcome(
                    revenue: plan.amount,
                    currency: "USD",
                    productId: plan.vendorId
                )
            } else {
                // Fallback if plan details aren't available
                context.log(.positive)
            }
        case .cancelled:
            context.log(.negative)
        case .restored:
            context.log(.skipped)
        @unknown default:
            context.log(.skipped)
        }
    }
)
```

{% hint style="info" %}
Revenue logging helps ContextSDK optimize not just for conversion rates, but for revenue maximization. Higher-value purchases can be weighted differently in the ML model training.
{% endhint %}

## Best Practices

### Choose a Flow Name

Select a descriptive flow name that represents your use case:

```swift
// Examples of good flow names:
ContextManager.instantContext(flowName: "purchasely_onboarding", duration: 3)
ContextManager.instantContext(flowName: "purchasely_premium_upgrade", duration: 3)
ContextManager.instantContext(flowName: "purchasely_feature_unlock", duration: 3)
ContextManager.instantContext(flowName: "purchasely_post_level", duration: 3)
```

Use a consistent naming pattern with `snake_case` and group related flows with the same prefix (e.g., all Purchasely flows start with `purchasely_`).

### Always Log Outcomes

**Critical:** Always log an outcome for every context you create. This data trains the ML model:

```swift
// Always log an outcome for every context
switch result {
case .purchased:
    context.log(.positive)
    // Or with revenue:
    // context.logRevenueOutcome(revenue: amount, currency: "USD", productId: id)
case .cancelled:
    context.log(.negative)
case .restored:
    // User restored purchases - this is not a conversion
    context.log(.skipped)
@unknown default:
    context.log(.skipped)
}
```

### Handle Multiple Placements

You can use this pattern across multiple placements in your app:

```swift
// During onboarding
func showOnboardingPaywall() {
    let context = ContextManager.instantContext(flowName: "purchasely_onboarding", duration: 3)
    presentPurchaselyPaywall(placement: "onboarding", context: context)
}

// After completing a key action
func showPostActionPaywall() {
    let context = ContextManager.instantContext(flowName: "purchasely_post_action", duration: 3)
    presentPurchaselyPaywall(placement: "post_action", context: context)
}

// In settings
func showSettingsPaywall() {
    let context = ContextManager.instantContext(flowName: "purchasely_settings", duration: 3)
    presentPurchaselyPaywall(placement: "settings", context: context)
}

private func presentPurchaselyPaywall(placement: String, context: Context) {
    // Set the context as a Purchasely user attribute
    Purchasely.setUserAttribute(withBoolValue: context.shouldUpsell, forKey: "context_should_upsell")

    let paywallController = Purchasely.presentationController(
        for: placement,
        loaded: { [weak self] controller, success, error in
            if let controller = controller, success {
                self?.present(controller, animated: true)
            } else {
                context.log(.skipped)
            }
        },
        completion: { result, _ in
            switch result {
            case .purchased:
                context.log(.positive)
            case .cancelled:
                context.log(.negative)
            case .restored:
                context.log(.skipped)
            @unknown default:
                context.log(.skipped)
            }
        }
    )
}
```

### Placement Naming Convention

Your ContextSDK flow names don't need to match your Purchasely placement IDs, but having a clear relationship helps maintain your code. For example:

* Flow name: `"purchasely_onboarding"` → Placement: `"onboarding"`
* Flow name: `"purchasely_settings"` → Placement: `"settings"`
* Flow name: `"purchasely_post_level"` → Placement: `"post_level"`

## Related Documentation

* [Revenue Outcomes](/context-decision/revenue-outcomes) - Learn more about logging revenue outcomes
* [Logging Conversions](/context-decision/logging-conversions) - General guide to logging outcomes
* [Custom Outcome Metadata](/context-decision/advanced/custom-outcome-metadata) - Track additional purchase context

{% hint style="success" %}
ContextSDK's ML models learn from your outcome data. The more purchases you log with `log(.positive)` or `logRevenueOutcome()`, the better the model becomes at predicting optimal moments to show paywalls.
{% endhint %}


# Logging Revenue with RevenueCat

Learn how to log conversion revenue from your in-app purchases if you're using RevenueCat.

ContextDecision supports logging conversions and revenue from any in-app purchase system, including RevenueCat.

Our revenue logging API supports both StoreKit 1 and StoreKit 2. RevenueCat abstracts away the StoreKit implementation, but it still exposes the underlying StoreKit objects if needed.

## StoreKit 2 Integration

To retrieve the StoreKit 2 product and log it, implement the `paywallViewController(_:didFinishPurchasingWith:)` delegate in your class that conforms to `PaywallViewControllerDelegate` as such:

```swift
extension MyDelegateClass: PaywallViewControllerDelegate {
    /// This delegate method notifies when a purchase has finished.
    func paywallViewController(_ controller: PaywallViewController, didFinishPurchasingWith customerInfo: CustomerInfo) {
        guard let latestEntitlement = extractLatestEntitlement(from: customerInfo) else {
            print("No entitlements found.")
            // Handle this scenario
            return
        }
        Purchases.shared.getProducts([latestEntitlement.productIdentifier]) { products in
            if let product = products.first {
                // Log or handle the StoreKit Product object
                if let context = ContextManager.recentContext(flowName: "my_upsell_flow") {
                    context.logRevenueOutcome(from: product.sk2Product)
                } else {
                    // Handle this scenario - make sure your Context is created before presenting the paywall, before reading it here
                }
            } else {
                print("Could not fetch product details for identifier: \(latestEntitlement.productIdentifier)")
                // Handle the error here
            }
        }
    }

    private func extractLatestEntitlement(from customerInfo: CustomerInfo) -> EntitlementInfo? {
        return customerInfo.entitlements.all.values
            .sorted(by: { ($0.latestPurchaseDate ?? .distantPast) > ($1.latestPurchaseDate ?? .distantPast) })
            .first
    }
}
```

## StoreKit 1 Integration

For apps still using StoreKit 1 (iOS 14 and earlier support), you can access the underlying SKProduct instead:

```swift
extension MyDelegateClass: PaywallViewControllerDelegate {
    /// This delegate method notifies when a purchase has finished.
    func paywallViewController(_ controller: PaywallViewController, didFinishPurchasingWith customerInfo: CustomerInfo) {
        guard let latestEntitlement = extractLatestEntitlement(from: customerInfo) else {
            print("No entitlements found.")
            // Handle this scenario
            return
        }
        Purchases.shared.getProducts([latestEntitlement.productIdentifier]) { products in
            if let product = products.first {
                // Log using StoreKit 1 SKProduct
                if let context = ContextManager.recentContext(flowName: "my_upsell_flow") {
                    context.logStoreKit1RevenueOutcome(from: product.sk1Product)
                } else {
                    // Handle this scenario - make sure your Context is created before presenting the paywall
                }
            } else {
                print("Could not fetch product details for identifier: \(latestEntitlement.productIdentifier)")
                // Handle the error here
            }
        }
    }

    private func extractLatestEntitlement(from customerInfo: CustomerInfo) -> EntitlementInfo? {
        return customerInfo.entitlements.all.values
            .sorted(by: { ($0.latestPurchaseDate ?? .distantPast) > ($1.latestPurchaseDate ?? .distantPast) })
            .first
    }
}
```

## Alternative: Using Transaction Information

If you need to log additional transaction-specific information (such as transaction state or date), you can also use the transaction-based approach:

```swift
// In your SKPaymentTransactionObserver implementation
func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
    for transaction in transactions {
        switch transaction.transactionState {
        case .purchased, .restored:
            // Get the associated product if available
            let productId = transaction.payment.productIdentifier
            Purchases.shared.getProducts([productId]) { products in
                let skProduct = products.first?.sk1Product
                
                if let context = ContextManager.recentContext(flowName: "my_upsell_flow") {
                    // Log with transaction and optional product context
                    context.logStoreKit1RevenueOutcome(from: transaction, product: skProduct)
                }
            }
            queue.finishTransaction(transaction)
        case .failed:
            // Handle failed transaction
            if let context = ContextManager.recentContext(flowName: "my_upsell_flow") {
                context.log(.negative)
            }
            queue.finishTransaction(transaction)
        default:
            break
        }
    }
}
```

{% hint style="success" %}
There's no need to call `context.log(.positive)` after logging a revenue outcome from a `Product` or `SKProduct`, as the positive outcome is already logged internally. Alternatively, you can customize the outcome to be logged directly, via `logStoreKit1RevenueOutcome(from:outcome:)` for StoreKit 1 or `logRevenueOutcome(from:outcome:)` for StoreKit 2.
{% endhint %}


# Logging Revenue with StoreKit 1

Learn how to log conversion revenue from StoreKit 1 in-app purchases for iOS 14 and earlier support

ContextSDK supports logging revenue from both StoreKit 1 and StoreKit 2 purchases. This guide covers StoreKit 1 integration, which is essential for apps that need to support iOS 14 and earlier, or are migrating from legacy StoreKit implementations.

## Basic StoreKit 1 Integration

### Using SKProduct

For basic product information logging, use the `SKProduct` directly:

{% code title="Example" %}

```swift
// When a purchase completes successfully
func userDidPurchase(product: SKProduct) {
    if let context = ContextManager.recentContext(flowName: "upsell_premium") {
        context.logStoreKit1RevenueOutcome(from: product)
    }
}
```

{% endcode %}

### Using SKPaymentTransaction

For more detailed transaction tracking, including transaction state and dates:

{% code title="SKPaymentTransactionObserver" %}

```swift
// In your SKPaymentTransactionObserver
func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
    for transaction in transactions {
        switch transaction.transactionState {
        case .purchased:
            handlePurchasedTransaction(transaction)
            queue.finishTransaction(transaction)
        case .restored:
            handleRestoredTransaction(transaction)
            queue.finishTransaction(transaction)
        case .failed:
            handleFailedTransaction(transaction)
            queue.finishTransaction(transaction)
        default:
            break
        }
    }
}

private func handlePurchasedTransaction(_ transaction: SKPaymentTransaction) {
    if let context = ContextManager.recentContext(flowName: "upsell_premium") {
        // Get the product information if available
        let productId = transaction.payment.productIdentifier
        if let product = loadedProducts[productId] {
            // Log with both transaction and product context
            context.logStoreKit1RevenueOutcome(from: transaction, product: product)
        } else {
            // Log with transaction only
            context.logStoreKit1RevenueOutcome(from: transaction)
        }
    }
}

private func handleFailedTransaction(_ transaction: SKPaymentTransaction) {
    if let context = ContextManager.recentContext(flowName: "upsell_premium") {
        context.log(.negative)
    }
}
```

{% endcode %}

To emphasize the transaction outcome handling, here are the steps for common transaction states:

{% stepper %}
{% step %}

#### Purchased

* Call your purchased handler (e.g., `handlePurchasedTransaction`).
* Log revenue using `context.logStoreKit1RevenueOutcome(from: transaction, product: product)` if you have product info, otherwise `context.logStoreKit1RevenueOutcome(from: transaction)`.
* Finish the transaction with `queue.finishTransaction(transaction)`.
  {% endstep %}

{% step %}

#### Restored

* Call your restored handler (e.g., `handleRestoredTransaction`).
* Log appropriate outcomes (similar to purchased if you want to attribute restored purchases).
* Finish the transaction with `queue.finishTransaction(transaction)`.
  {% endstep %}

{% step %}

#### Failed

* Call your failed handler (e.g., `handleFailedTransaction`).
* Log a negative outcome with `context.log(.negative)` if appropriate.
* Finish the transaction with `queue.finishTransaction(transaction)`.
  {% endstep %}
  {% endstepper %}

## Metadata Extracted from StoreKit 1

When using StoreKit 1 products and transactions, ContextSDK automatically extracts the following metadata:

### From SKProduct

* Revenue: `product.price`
* Currency: `product.priceLocale.currencyCode` (defaults to "USD" if unavailable)
* Revenue Source: Always set to "in\_app\_purchase"
* Family Sharing: `product.isFamilyShareable` (iOS 14.0+ only)
* Recurrence: "recurring" for subscriptions, "one\_time\_purchase" for others
* Subscription Period: Period unit and interval (for subscriptions)
* Introductory Offers: Price, period, and duration (iOS 11.2+ only)

### From SKPaymentTransaction (additional)

* Transaction ID: `transaction.transactionIdentifier`
* Transaction State: "purchased", "failed", "restored", etc.
* Transaction Date: ISO8601 formatted transaction date
* Original Transaction ID: For subscription renewals and restores

{% hint style="info" %}
StoreKit 1 provides less metadata than StoreKit 2, particularly around subscription details and family sharing (which requires iOS 14.0+). The SDK handles these differences automatically and provides sensible defaults.
{% endhint %}


# Adding Entry Points

Adding entry points is essential for ContextDecision to deliver maximum value to you - learn why it’s required and how to implement them.

## What are entry points?

Entry points are triggers in your app that may show an upsell to your users.

In those moments, your app invokes ContextSDK to capture and analyze the user’s context.

These contexts allow the ML model to determine the next action - such as show an upsell screen, an ad, an app rating prompt, or skip the flow completely. The actions are tailored to your app’s specific use case.

<figure><img src="/files/M1tUnZ0uygQ6ZDJebMgg" alt=""><figcaption><p>Examples of entry points in an app that offers subscriptions</p></figcaption></figure>

## Why is adding entry points important?

<figure><img src="/files/lHPRuNiJfXQoz0A1Dkn7" alt=""><figcaption><p>This is what would likely happen if the goal was just to increase the conversion rate</p></figcaption></figure>

When integrating ContextDecision, it's crucial that you increase the entry points in your app, to increase the number of sales and revenue.

In the image above, the app is initially showing prompts in both good and bad moments. Once ContextDecision is implemented, it will only show prompts in good moments - when users are more likely to convert - thus showing less prompts, with higher conversion. However, if your primary goal is to generate more revenue, showing fewer prompts may not achieve that objective.

<figure><img src="/files/PiR6ODo3xJdG1D6gnXJE" alt=""><figcaption><p>In this example, the app had a 50% increase in sales by adding new entry points alongside ContextDecision</p></figcaption></figure>

## How can I add more entry points?

There are two types of entry points:

* Opportunities that refine existing algorithms, leveraging ContextDecision's ML decision-making to enhance or completely replace the logic previously used to trigger your upsell.
  * Example: a dating app currently shows an upsell prompt once every 10 profile swipes. We could tweak this number to 1 every 5, 1 every 2, or even on every swipe. See [#wont-this-annoy-the-users-even-more-by-spamming-them](#wont-this-annoy-the-users-even-more-by-spamming-them "mention")
* Opportunities that introduce new triggers, expanding the potential for upsells by incorporating additional moments into your app.
  * Example: an app shows an upsell prompt on **every** app launch. In this case there's no way we can increase the number of prompts shown, because it's already shown on every app launch (being as annoying as possible). In this case we'd need to introduce a new opportunity somewhere else, e.g. every time the user completes a key positive action within the app, tailored to the app’s specific industry.

First, determine whether the flow you’re enhancing with ContextDecision already uses existing algorithms, as shown in the first example above. If it does, that’s great news - it’s the easiest to refine and improve. If your current flow aligns more with the second type, you’ll need to explore new user journeys in your app where adding an upsell opportunity makes sense. These entry points often arise after key positive interactions or in other carefully timed moments.

After identifying where to introduce new entry point, collaborate with our technical team to configure a model tailored to this new flow. **Ensure this configuration is complete before rolling out the new flow to production.**

## Won't this annoy the users even more by spamming them?

No - that’s the strength of ContextDecision.

ContextDecision show prompts only in the best moments, thus reducing the number of prompts shown. However, to keep showing the same number of prompts as before, it’s essential to introduce new entry points.

If these two actions - refining prompt timing and increasing entry points - aren’t implemented together, you risk either reducing the total number of prompts (potentially decreasing revenue) or increasing them unnecessarily (which could frustrate users). For the best results, these steps should occur simultaneously: deploying the ML model and adding new entry points.

### Disabling new entry points during calibration phase

During the calibration phase, we recommend the new entry points to be "live", but disabled. There are 2 ways to disable them during the calibration phase:

* Inform us which flows are the new ones that should be disabled, and we'll disable them remotely by always sending `shouldUpsell = false` to your flow, during the calibration phase.
* Check if the model info exists and update your implementation accordingly:

{% tabs %}
{% tab title="iOS" %}

```swift
let modelInfo = ContextManager.getCustomModel(for: "your_upsell_flow")
let isInCalibrationPhase = modelInfo == nil
```

{% endtab %}

{% tab title="Android" %}
{% tabs %}
{% tab title="Kotlin" %}
Not yet supported in Android. Reach out to our team to request this feature.
{% endtab %}

{% tab title="Java" %}
Not yet supported in Android. Reach out to our team to request this feature.
{% endtab %}
{% endtabs %}
{% endtab %}

{% tab title="Flutter" %}
Not yet supported in Flutter. Reach out to our team to request this feature.
{% endtab %}

{% tab title="Unity" %}
Not yet supported in Unity. Reach out to our team to request this feature.
{% endtab %}

{% tab title="React Native" %}
Not yet supported in React Native. Reach out to our team to request this feature.
{% endtab %}
{% endtabs %}

## How many entry points should I add?

It depends on your current prompt volume and your ability to increase it through existing and new flows.

<figure><img src="/files/ynZRmwLfcumUwGkrZb3x" alt=""><figcaption><p>Example showing 2 new entry points added, which was enough due to their volume</p></figcaption></figure>

In the example above, the app shows 100k prompts per week. At a 50% prompt intensity for the existing flow, it would need to add new entry points with enough volume to supply the suppressed 50k prompts per week. In the example, this was done by adding 2 new entry points, so that the final number of prompts shown per week remained the same.

See the chart below for an illustrative relationship between prompt intensity (percentage of prompts shown) and potential conversion rate improvements.

<div align="center" data-full-width="false"><figure><img src="/files/yK7qu2I1tzvnm9QEDOhG" alt="" width="436"><figcaption><p>Sample showing the relation between % of prompt shown and % of conversion rate increase</p></figcaption></figure></div>

To achieve this, evaluate your app’s unique use cases and user behavior. New flows or refinements to existing ones may unlock the entry points you need.

{% hint style="info" %}
Note that in the case of constantly evaluating the context, such as in the dating app example where the context would be evaluated multiple times per minute, your app is in charge of implementing a cooldown mechanism that prevents a prompt from being displayed multiple times subsequently, as ContextSDK doesn't embed this behavior internally.
{% endhint %}

{% hint style="info" %}
The numbers used in this page are used just to illustrate and don't represent the actual gain in conversion or revenue your app will see. Each app and industry have their own user behavior patterns and we create a bespoke ML model for each app.
{% endhint %}


# Release Checklist

Validate your ContextDecision integration before releasing to the App Store or Google Play with this checklist.

Before you ship your first release with the ContextSDK, we recommend taking a few steps to validate the integration works as expected.

{% stepper %}
{% step %}

#### **Validate the Context**

1. Add the following log right after wherever you [capture the `context`](https://docs.contextsdk.com/context-decision/logging-conversions#capturing-context) object:

{% tabs %}
{% tab title="iOS" %}

```swift
print(context.validate())
```

{% endtab %}

{% tab title="Android" %}
{% tabs %}
{% tab title="Kotlin" %}

```kotlin
Log.d("ContextSDK", context.validate())
```

{% endtab %}

{% tab title="Java" %}

```java
Log.d("ContextSDK", context.validate());
```

{% endtab %}
{% endtabs %}
{% endtab %}

{% tab title="Flutter" %}

```dart
print(await context.validate());
```

{% endtab %}

{% tab title="Unity" %}

```csharp
Debug.Log(context.Validate());
```

{% endtab %}

{% tab title="React Native" %}

```javascript
const output = await context.validate();
console.log(output); // Or print it anywhere else
```

{% endtab %}
{% endtabs %}

2. Run your app on a **physical device**, not on a simulator or emulator.
3. Navigate to the screen where you access ContextSDK. Make sure all the requirements you've implemented in your app are met (e.g. the user has not purchased the product, the user has not seen the upsell before, etc.).
4. You should see the following output printed, with the numbers varying depending on the current context:

```
✅ Number of Signals: 340 (this number may be higher)
✅ Flow name provided: your_flow_name
✅ Accelerometer Data: Valid
👉 Signal Sample 1: 0.0 (if your device was flat on the table not moving, this should be 0.0)
👉 Signal Sample 2: 0.0059286836 (this should never be exactly 0.0)
✅ Calibration Mode active
✅ ContextSDK License: Active
👉 ContextSDK Version: 5.17.0
```

5. If you notice the ContextSDK version not being 5.17.0, please follow the instructions in [Updating Your SDK](/other/updating-your-sdk).
   {% endstep %}

{% step %}

#### **Verify each flow has a unique name**

Each flow should have a distinct name, even if the same flow is triggered from different parts of the app. We automatically analyze the overlap between different flows, to decide if there should be one, or more models.

**Best practice**: use `snake_case` and group flows that lead to the same prompt using the same prefix, e.g. `upsell_onboarding`, `upsell_first_action` .
{% endstep %}

{% step %}

#### **Verify the moment you are capturing context and logging outcome**

It is critical that you capture the `context` object **right before** you show the upsell prompt. This guarantees that the signals are taken at the correct moment (as context can change quickly), and also allows you to start making decisions once your custom model is ready.

Similarly, you should log the outcome **after the user has interacted** with the upsell prompt.

Failing to follow these guidelines will likely show console warnings, such as a message indicating that you’re logging an outcome immediately after creating the context, as this approach is generally considered incorrect.
{% endstep %}

{% step %}

#### **Verify you are correctly logging all outcome scenarios**

You need to ensure you always log at least one outcome for each time you access the current context:

<figure><img src="/files/ohJ6tq7QSi2wZSJa5KWN" alt=""><figcaption></figcaption></figure>

Also, make sure both positive and negative code paths are tested before releasing your app. Forgetting to log one or the other usually invalidates the data collected and delays the training of your custom model.

We recommend logging `.positive` only after the user successfully completes a purchase.

If the user dismisses the iOS payment sheet or encounters a transaction error (e.g., due to network issues), avoid logging an outcome immediately. Instead, log a new [Custom Outcome Metadata](/context-decision/advanced/custom-outcome-metadata) and allow the user to retry (e.g., by keeping the paywall screen open after a payment error). Log the final outcome later, such as when the user either dismisses the paywall or successfully completes the purchase.

Alternatively, you can be more fine-grained with your logging and use [Alternative Outcomes](/context-decision/advanced/alternative-outcomes).

When logging outcome metadata, be sure to call `context.log(…)` afterward. The metadata is transmitted to the server only when an outcome is logged.

Also, avoid logging multiple outcomes for the same context without updating metadata between them. For example, if a user dismisses the upsell prompt, logging a `.negative` outcome repeatedly without changing metadata isn’t necessary. Update the metadata between each outcome, or log the outcome only once.
{% endstep %}

{% step %}

#### **Ensure no errors are logged by the SDK**

When running your app with ContextSDK installed on a real device, ensure that your IDE doesn't print out any ContextSDK-related errors (search console logs for "ContextSDK").
{% endstep %}
{% endstepper %}


# Advanced

Learn more about advanced configurations or use cases for ContextDecision.

* [Custom Signals](/context-decision/advanced/custom-signals)
* [Alternative Outcomes](/context-decision/advanced/alternative-outcomes)
* [Custom Outcome Metadata](/context-decision/advanced/custom-outcome-metadata)
* [Listening for Good Moments](/context-decision/advanced/listening-for-good-moments)
* [Model Distribution Methods](/context-decision/advanced/model-distribution-methods)
* [Custom A/B Test Segmentation](/context-decision/advanced/custom-ab-test-segmentation)
* [Analytics & Reporting](/context-decision/advanced/analytics-and-reporting)


# Custom Signals

Custom signals supplement built-in sensor data, improving decision-making and optimization for your use case.

## What are custom signals?

Custom signals allow you to provide additional context when capturing user interactions. ContextSDK processes more than 300 built-in signals, but you can enhance its predictions by including app-specific data — such as in-game progress, friend count, or prior interactions like ad views or feature usage. Any past event that may influence a user’s decision-making process is valuable to include.

{% hint style="warning" %}
Important: Never send personally identifiable information (PII) to the SDK. See [Privacy Considerations](https://docs.contextsdk.com/context-decision/advanced/custom-signals#privacy-considerations) to learn more.
{% endhint %}

## Why add custom signals?

By adding custom signals, you can:

* Provide additional context when making upsell or engagement decisions to improve the precision of ML models.
* Optimize monetization and user experience based on real-time behavioral data.

Despite that, it's worth noting that adding custom signals is optional. Not all apps and businesses have environment- or user-specific signals that would make a significant difference when deciding whether to show an upsell offer or not.

## How to add custom signals

You can define custom signals at two levels:

### Global Signals

Global signals apply to all context evaluations and persist across different flows. Use them for app-wide metrics, such as total session count or the number of purchases made. Usage example:

{% tabs %}
{% tab title="iOS" %}

```swift
ContextManager.setGlobalCustomSignal(id: "number_of_levels_completed", value: 21)
```

{% endtab %}

{% tab title="Android" %}
{% tabs %}
{% tab title="Kotlin" %}

```kotlin
ContextSDK.globalCustomSignals["number_of_levels_completed"] = 21
```

{% endtab %}

{% tab title="Java" %}

```java
ContextSDK.Companion.getGlobalCustomSignals().set("number_of_levels_completed",
21);
```

{% endtab %}
{% endtabs %}
{% endtab %}

{% tab title="Flutter" %}

```dart
_contextSdkPlugin.setGlobalCustomSignals({
  'number_of_levels_completed': 21,
});
```

{% endtab %}

{% tab title="Unity" %}

```csharp
ContextSDKBinding.SetGlobalCustomSignal("number_of_levels_completed", 21);
```

{% endtab %}

{% tab title="React Native" %}

```javascript
setGlobalCustomSignals({ number_of_levels_completed: 21 })
```

{% endtab %}
{% endtabs %}

To update a signal, call `setGlobalCustomSignal(id:value:)` with the same ID and a new value. To remove a global signal, pass a `null-like` value.

### Flow-Specific Signals

Use flow-specific signals when a signal is relevant only to a particular flow. These signals are passed as parameters when capturing a context. Usage example:

{% tabs %}
{% tab title="iOS" %}

```swift
let customSignals: [CustomSignal] = [
    CustomSignalBool(id: "watched_ad", value: true),
    CustomSignalFloat(id: "percentage_onboarding_finished", value: 0.3),
    CustomSignalString(id: "upsell_copy_button_used", value: "Purchase Premium"),
]
ContextManager.fetchContext(flowName: "onboarding_upsell", customSignals: customSignals) { context in
    // Handle context evaluation
}
```

{% endtab %}

{% tab title="Android" %}
{% tabs %}
{% tab title="Kotlin" %}

```kotlin
val customSignals = CustomSignals(
    mapOf(
        "number_of_friends" to SignalValue.IntValue(4),
        "percentage_onboarding_finished" to SignalValue.FloatValue(0.3f),
        "upsell_copy_button_used" to SignalValue.StringValue("Purchase Premium"),
    )
)
ContextSDK.fetchContext("onboarding_upsell", 3, customSignals) { context ->
    // Handle context evaluation
}
```

{% endtab %}

{% tab title="Java" %}

```java
CustomSignals customSignals = new CustomSignals();
customSignals.set("number_of_friends", 4);
customSignals.set("percentage_onboarding_finished", 0.3f);
customSignals.set("upsell_copy_button_used", "Purchase Premium");

ContextSDK.Companion.fetchContext("onboarding_upsell", 3, customSignals, realWorldContext -> {
    // Handle context evaluation
});
```

{% endtab %}
{% endtabs %}
{% endtab %}

{% tab title="Flutter" %}

```dart
_contextSdkPlugin.fetchContext("onboarding_upsell", customSignals: {
  'watched_ad': true,
  'percentage_onboarding_finished': 0.3,
  'upsell_copy_button_used': 'Purchase Premium',
}, (context) async {
  // Handle context evaluation
})
```

{% endtab %}

{% tab title="Unity" %}

```csharp
CustomSignals customSignals = new CustomSignals();
customSignals.AppendCustomSignal("watched_ad", true);
customSignals.AppendCustomSignal("percentage_onboarding_finished", 0.3f);
customSignals.AppendCustomSignal("upsell_copy_button_used", "Purchase Premium");

ContextSDKBinding.FetchContext("onboarding_upsell", delegate (Context context) {
    // Handle context evaluation
}, customSignals);
```

{% endtab %}

{% tab title="React Native" %}

```javascript
fetchContext({
  flowName: 'onboarding_upsell',
  onContextReady: async (context) => {
    // Handle context evaluation
  },
  customSignals: {
    watched_ad: true,
    percentage_onboarding_finished: 0.3,
    upsell_copy_button_used: 'Purchase Premium',
  },
});
```

{% endtab %}
{% endtabs %}

Flow-specific signals ensure that only relevant data is included in decision-making for a particular flow.

## Signal ID and value requirements

A signal ID must be non-empty and should contain only letters, numbers and underscores (for example `number_of_levels_completed`), and should be at most 128 characters long. Any other character is replaced with an underscore when a model refers to the signal, so `cart-value` and `cart.value` become the same signal name and collide with each other.

Numeric values must be finite. A `NaN` or infinite float or decimal value is discarded, and the signal is not recorded for that context.

The SDK logs a message to the console when you set a signal it cannot use, so watch the console output while integrating. Each problem is logged once per signal ID, not once per call.

## Privacy Considerations

When using custom signals, ensure that no personally identifiable information (PII) is included. ContextSDK does not process sensitive data, such as:

* User IDs
* Email addresses or phone numbers
* IP addresses
* Exact locations
* Date of birth or age
* Gender

You are responsible for ensuring that you have the necessary rights to provide data to ContextSDK.

By leveraging custom signals, you can refine ContextSDK’s predictions and optimize monetization strategies — all while maintaining user privacy and security.


# Tracking the User ID

Associate ContextSDK events with the user identifier from your own system so the same user can be matched across both platforms.

## What is the user ID?

The user ID is the identifier you already use to recognise a user in your own systems. By passing it to ContextSDK, every context event is tagged with that same identifier, so you can match a user between your platform and ContextSDK.

## Why track the user ID?

Sharing a common user identifier lets you join ContextSDK's analytics with your own data, making it easier to reason about a single user across both systems.

## How to track the user ID

Call `setUserId(_:)` with the identifier of the currently signed-in user. The value is stored on the device, so it's reapplied automatically on the next app start, and it's attached to every event from that point on. The same user ID is shared across all ContextSDK products, so this single call also associates the user with your push notifications.

{% tabs %}
{% tab title="iOS" %}

```swift
ContextManager.setUserId("your-user-id")
```

{% endtab %}
{% endtabs %}

Call this again whenever the signed-in user changes to overwrite the previous value.

## Clearing the user ID

Pass `nil` to clear a previously set user ID, for example when the user signs out. This removes the stored identifier from the device and stops attaching it to events.

{% tabs %}
{% tab title="iOS" %}

```swift
ContextManager.setUserId(nil)
```

{% endtab %}
{% endtabs %}


# Alternative Outcomes

Learn how to log the appropriate user interaction for in-app offers and banners.

When logging user interactions, it's important to select the most appropriate outcome type to accurately capture user behavior. The SDK provides generic outcomes for immediate user decisions, as well as additional outcomes for more detailed interactions, such as banner interactions or ads.

If your flow has just 2 potential outcomes, we strongly recommend using simply `.positive` and `.negative` outcomes, to avoid extra complexities. However, if your flow has more than 2 potential outcomes, the following outcomes can be used for further distinction:

* `.positive`: A generic positive outcome, typically used when the user makes an immediate decision (e.g., responding to a full-screen prompt).
  * `.positiveInteracted`: User interacted with the banner and started the purchase flow, or read more about the offer.
  * `.positiveConverted`: User successfully completed the intended action (e.g., purchase).
  * `.positiveAdTapped`: User tapped an advertisement or promotional element.
* `.negative`: A generic negative outcome, typically used when the user makes an immediate decision (e.g. dismissing a full-screen prompt).
  * `.negativeNotInteracted`: User did not engage with the banner in any way.
  * `.negativeDismissed`: User explicitly dismissed the banner (e.g. using a close button).

See practical examples for these alternative outcomes in [Multivariate Monetization](/use-cases/multivariate-monetization) and [Inline Banners](/use-cases/inline-banners).

## The `skipped` outcome

A skipped outcome is neither positive or negative. It's designed to indicate actions that either ended up not showing the offer at all, or one that invalidated the flow. Examples:

* Your context's <kbd>shouldUpsell</kbd> is `true`, but there is custom logic in your app that prevents the offer from being shown, such as a cooldown mechanism.
* You present a paywall that has a "Restore Purchases" button. This action is neither negative, nor really positive, as there was no real "conversion" at that time, so we recommend logging the `skipped` outcome alongside a custom outcome metadata with key <kbd>used\_restore\_purchase\_button</kbd> and value `true`.


# Custom Outcome Metadata

Learn how to log custom outcome metadata to analyze user behavior and optimize monetization performance.

## What is outcome metadata?

Outcome Metadata lets you attach additional details to a logged outcome, providing context about user behavior after an upsell offer is shown. For example, you can track whether a user clicked to learn more before making a purchase or interacted with other UI elements. ContextSDK records this information alongside the outcome, offering deeper insights into user behavior and monetization performance.

## How do outcome metadata differ from custom signals and revenue outcomes?

[Custom Signals](/context-decision/advanced/custom-signals) provide additional context to the machine learning model at the time of context capture, allowing them to influence decision-making. In contrast, outcome metadata is recorded after a context has been captured and does not affect the decision-making process.

[Revenue Outcomes](/context-decision/revenue-outcomes) are a specialized type of metadata that have dedicated APIs due to their importance. Use revenue outcomes to log details about in-app purchases, which will automatically include the product purchased, revenue, recurrence model, and other relevant metadata, without any extra effort.

## Why log outcome metadata?

Outcome metadata helps provide a deeper understanding of user behavior. It can also indicate whether your app overrode the ML model’s decision — for example, due to an upsell offer cooldown mechanism. By capturing this data, you can improve reporting accuracy and ensure the necessary data is available to refine and enhance future models.

{% hint style="info" %}
While metadata enriches outcome tracking, it does not influence **when** an upsell offer is shown. If you need to factor in additional signals for determining whether to display an offer, consider using [Custom Signals](/context-decision/advanced/custom-signals) instead.
{% endhint %}

## How to log outcome metadata

Use `appendOutcomeMetadata(_:)` to attach metadata to a context before logging an outcome. The metadata will only be recorded if an outcome is logged.

{% tabs %}
{% tab title="iOS" %}

```swift
context.appendOutcomeMetadata([
    CustomSignalString(id: "path", value: "flow_2"),
    CustomSignalBool(id: "clicked_learn_more", value: true),
])
```

After appending metadata, ensure that an outcome (whether positive or negative) is logged, e.g.:

```swift
context.logRevenueOutcome(from: product)
```

{% endtab %}

{% tab title="Android" %}
{% tabs %}
{% tab title="Kotlin" %}

```kotlin
context.outcomeMetadata["path"] = "flow_2"
context.outcomeMetadata["clicked_learn_more"] = true
```

After appending metadata, ensure that an outcome (whether positive or negative) is logged, e.g.:

```swift
context.log(EventOutcome.POSITIVE)
```

{% endtab %}

{% tab title="Java" %}

```java
context.getOutcomeMetadata().set("path", "flow_2");
context.getOutcomeMetadata().set("clicked_learn_more", true);
```

After appending metadata, ensure that an outcome (whether positive or negative) is logged, e.g.:

```swift
context.log(EventOutcome.POSITIVE);
```

{% endtab %}
{% endtabs %}
{% endtab %}

{% tab title="Flutter" %}

```dart
context.appendOutcomeMetadata({
  'path': 'flow_2',
  'clicked_learn_more': true,
});
```

After appending metadata, ensure that an outcome (whether positive or negative) is logged, e.g.:

```dart
await context.log(Outcome.positive);
```

{% endtab %}

{% tab title="Unity" %}

```csharp
context.AppendOutcomeMetadata("path", "flow_2");
context.AppendOutcomeMetadata("clicked_learn_more", true);
```

After appending metadata, ensure that an outcome (whether positive or negative) is logged, e.g.:

```csharp
context.Log(Outcome.Positive);
```

{% endtab %}

{% tab title="React Native" %}

```javascript
await context.appendOutcomeMetadata({
    path: 'flow_2',
    clicked_learn_more: true,
});
```

After appending metadata, ensure that an outcome (whether positive or negative) is logged, e.g.:

```javascript
await context.log(Outcome.positive);
```

{% endtab %}
{% endtabs %}

Appending metadata with the same ID multiple times replaces the previous value. This ensures only the most recent data is retained. To remove a value, pass `nil`.


# Listening for Good Moments

Register a background observer to trigger a callback when it’s a good moment to engage the user.

You can register a persistent context observer that evaluates the user’s real-world context in the background and triggers a callback when a good moment is detected. This is useful for scenarios like showing an in-app message or prompt at the right time, without requiring repeated manual checks.

{% hint style="warning" %}
This API is only available in iOS at the moment. Please reach out to our team if you need this API in Android.
{% endhint %}

### Registering an Observer

Use `registerContextObserver(flowName:callbackOnFirstGoodMoment:)` to begin monitoring for good moments:

```swift
ContextManager.registerContextObserver(flowName: "upsell_in_app_message") { context in
    // Present your upsell here and then log its positive/negative outcome
}
```

Once a good moment is detected:

* The callback is triggered on the main thread.
* The observer is automatically unregistered for that flow name.

### Logging Outcomes

There is a key difference in this observer when compared to traditional [Logging Conversions](/context-decision/logging-conversions#capturing-context) methods: don't log a `skipped` outcome anywhere. The `shouldUpsell` property will always be `true` when this callback is called, so there's no need to check it, nor log `skipped`.

Logging positive and negative outcomes (including [Alternative Outcomes](/context-decision/advanced/alternative-outcomes)) should happen as usual. For more details on when and how they should be logged, see [Logging Conversions](/context-decision/logging-conversions).

### Unregistering an Observer

You can cancel an active observer at any time using:

```swift
ContextManager.unregisterContextObserver(flowName: "upsell_in_app_message")
```

This is safe to call multiple times — if no observer is registered, the call has no effect.

## Calibration Phase

During the [Glossary](/other/glossary#calibration-phase), This observer also supports collecting data during the calibration phase.

## Best Practices & Caveats

* **One observer per flow:** registering another observer with the same flow name replaces the previous one.
* **Multiple flows are independent:** using different flow names will create multiple independent observers. These observers are not triggered simultaneously, helping you avoid overlapping user prompts.
* **Callback is called only once:** if you want to listen again after a good moment was found, re-register the observer.

## FAQs

#### Why does the observer unregisters itself after identifying the first good moment?

Due to the ongoing evaluation of the context, the user’s context is unlikely to have changed significantly. Without a built-in safeguard, multiple callbacks in quick succession could result in repeated in-app messages, leading to potential UX issues.

Rather than requiring each developer to implement their own cooldown logic (for example, limiting messages to once per hour), the SDK takes a conservative approach: it removes the observer after the first callback. If your app needs to continue listening for updates, you can re-register the observer as needed.

This approach reduces the risk of flooding users with repeated upsell screens or modals, especially in production environments where missing cooldown logic may go unnoticed during testing.

#### How does model training work during the calibration phase?

During the calibration phase, prompts are shown at random times, similar to how the control group operates after calibration.

For example, if the agreed [Glossary](/other/glossary#prompt-intensity) is 1%, the system evaluates context continuously but displays a prompt at random only 1% of the time. These randomly triggered prompts and their outcomes are then used to train the model.

Once the initial model is deployed, the audience is split:

* The control group continues to see prompts with a 1% random chance.
* The ContextSDK group sees prompts based on the top 1% of context-optimized moments, as determined by the trained model.

This approach ensures the model is trained on unbiased data while maintaining a consistent baseline for comparison.\\


# Model Distribution Methods

Learn about the different distribution methods of your ML models.

After our team trains your custom model using your app's data, it can be deployed directly to your users. A custom-trained model ensures optimal performance tailored specifically to your application and user base.

There are two distribution methods:

{% tabs %}
{% tab title="Over-the-Air (Recommended)" %}
We recommend distributing your machine learning models directly to your app over-the-air. This method provides instant updates, requires minimal system resources, and allows us to iterate faster and more efficiently to further improve your app's performance.

Our OTA rollouts are safe, reliable, and won't affect your app's performance. Our systems continuously monitor the rollout and the resulting conversion performance to ensure everything is working as expected.
{% endtab %}

{% tab title="Custom SDK Binary" %}
Typically, our team will inform you, based on the flows of your app, whether over-the-air updates are possible. If they're not possible, or if you simply choose not to use over-the-air updates, we offer the option of embedding your custom model within a custom-built SDK binary.

To request a custom SDK binary, contact us at <support@contextsdk.com>.
{% endtab %}
{% endtabs %}

See [Analytics & Reporting](/context-decision/advanced/analytics-and-reporting) to learn how to monitor model rollouts.


# Custom A/B Test Segmentation

Learn how to configure A/B test segmentation using our built-in mechanism or manage it manually for full control.

Each machine learning model deployed to your app includes an associated [A/B test split](https://docs.contextsdk.com/other/glossary#a-b-test-split). This allows us to accurately compare performance data between control and treatment groups, ensuring that observed changes are driven by our model rather than by random variation or other product-level adjustments.

We provide two options for configuring the A/B test split:

### Built-In A/B Test Mechanism (Recommended)

We recommend using our built-in A/B test mechanism, which automates split assignment, deployment, and metric adjustments on your behalf.

No action is required to opt in to this approach.

Refer to [Analytics & Reporting](/context-decision/advanced/analytics-and-reporting) to learn how to report user segmentation groups to your internal analytics system.

### Custom A/B Test Segmentation

If your app requires direct control over A/B test segmentation, we also support that, and it takes 2 simple steps:

#### Step 1: Segment Your Users

{% tabs %}
{% tab title="iOS" %}

```swift
ContextManager.setDecisionsEnabled(enabled: false)
```

{% endtab %}

{% tab title="Android" %}
Not yet supported in Android. Reach out to our team to request this feature.
{% endtab %}

{% tab title="Flutter" %}
Not yet supported in Flutter. Reach out to our team to request this feature.
{% endtab %}

{% tab title="Unity" %}
Not yet supported in Unity. Reach out to our team to request this feature.
{% endtab %}

{% tab title="React Native" %}
Not yet supported in React Native. Reach out to our team to request this feature.
{% endtab %}
{% endtabs %}

Passing `false` assigns the user to the control group, preventing the SDK from making any decisions, as `shouldUpsell` will always be `true` then. Passing `true` allows the SDK to utilize the machine learning model to optimize conversions, provided that a model has already been deployed to the designated flow.

{% hint style="warning" %}
Even when using `setDecisionsEnabled`, it’s important to keep all the context capturing and logging fully operational for both cohorts (control and treatment) as this is critical to train the ML models.

The SDK initialization via `ContextManager.applicationDidFinishLaunchingWithOption` and other configurations should **also** remain unchanged.
{% endhint %}

#### Step 2: Send Us Your Cohort Information

In order to correctly analyze your data you should send us details about your A/B test cohorts. We provide a dedicated API for this:

{% tabs %}
{% tab title="iOS" %}
First, configure a default AB test assignment in your `Configuration`:

```swift
let config = Configuration(
    defaultABTestInformation: ABTestInformation(
        testName: "none", 
        cohortName: "control"
    )
)
ContextManager.applicationDidFinishLaunchingWithOptions(
    launchOptions, 
    licenseKey: "your_license_key", 
    configuration: config
)
```

Then, when your app determines the user's cohort assignment, call:

```swift
// For users in the treatment group
ContextManager.setABTestInformation(testName: "context_sdk", cohortName: "treatment")

// For users in the control group
ContextManager.setABTestInformation(testName: "context_sdk", cohortName: "control")
```

{% hint style="info" %}
The new `setABTestInformation` method replaces the previous approach using custom signals and provides better analytics tracking.
{% endhint %}
{% endtab %}

{% tab title="Android" %}
{% tabs %}
{% tab title="Kotlin" %}

```kotlin
ContextSDK.globalCustomSignals["ab_test_name"] = "none"
// OR
ContextSDK.globalCustomSignals["ab_test_name"] = "context_sdk"

ContextSDK.globalCustomSignals["ab_test_cohort"] = "control"
// OR
ContextSDK.globalCustomSignals["ab_test_cohort"] = "treatment"
```

{% endtab %}

{% tab title="Java" %}

```java
ContextSDK.Companion.getGlobalCustomSignals().set("ab_test_name", "none");
// OR
ContextSDK.Companion.getGlobalCustomSignals().set("ab_test_name", "context_sdk");

ContextSDK.Companion.getGlobalCustomSignals().set("ab_test_cohort", "control");
// OR
ContextSDK.Companion.getGlobalCustomSignals().set("ab_test_cohort", "treatment");
```

{% endtab %}
{% endtabs %}
{% endtab %}

{% tab title="Flutter" %}

```dart
_contextSdkPlugin.setGlobalCustomSignals({
  'ab_test_name': 'none',
  // OR
  // 'ab_test_name': 'context_sdk',
});

_contextSdkPlugin.setGlobalCustomSignals({
  'ab_test_cohort': 'control',
  // OR
  // 'ab_test_cohort': 'treatment',
});
```

{% endtab %}

{% tab title="Unity" %}

```csharp
ContextSDKBinding.SetGlobalCustomSignal("ab_test_name", "none");
// OR
ContextSDKBinding.SetGlobalCustomSignal("ab_test_name", "context_sdk");

ContextSDKBinding.SetGlobalCustomSignal("ab_test_cohort", "control");
// OR
ContextSDKBinding.SetGlobalCustomSignal("ab_test_cohort", "treatment");
```

{% endtab %}

{% tab title="React Native" %}

```javascript
setGlobalCustomSignals({ ab_test_name: 'none' });
// OR
setGlobalCustomSignals({ ab_test_name: 'context_sdk' });

setGlobalCustomSignals({ ab_test_cohort: 'control' });
// OR
setGlobalCustomSignals({ ab_test_cohort: 'treatment' });
```

{% endtab %}
{% endtabs %}

#### Step 3: Inform Us Of The Segmentation Split

Since your app controls the split, we request that you inform our team of the initial split percentage and notify us of any planned changes, as this impacts our reporting.


# Analytics & Reporting

Learn how to track ContextDecision metrics.

### Tracking A/B Test Segmentation

Tracking which group a user is assigned to (e.g. Control or Treatment) is important if you want to compare each cohort's performance. This is even more evident for long-term metrics such as LTV and user churn.

To determine which group a user was assigned to, can use the following code:

{% tabs %}
{% tab title="iOS" %}

```swift
let modelInfo = ContextManager.getCustomModel(for: "your_upsell_flow")
let isControl = modelInfo?.isControl ?? true
```

{% endtab %}

{% tab title="Android" %}
Not yet supported in Android. Reach out to our team to request this feature.
{% endtab %}

{% tab title="Flutter" %}
Not yet supported in Flutter. Reach out to our team to request this feature.
{% endtab %}

{% tab title="Unity" %}
Not yet supported in Unity. Reach out to our team to request this feature.
{% endtab %}

{% tab title="React Native" %}
Not yet supported in React Native. Reach out to our team to request this feature.
{% endtab %}
{% endtabs %}

When the model info is `nil`, it means the ML model hasn't been deployed yet (i.e. we're still in calibration phase). When present, a ML model has already been deployed, and we can access the `isControl` property.

### Tracking OTA Model Rollouts

To track which over-the-air model is currently active in the app, ContextDecision exposes a `ContextManager.currentAppliedCustomModelVersion` function. This returns a unique identifier that gets updated every time the app receives a new model version. You can track this in your own analytics to observe model rollouts.

During calibration phase, this function will always return `"calibration"`, and when opting for [custom SDK binaries](/context-decision/advanced/model-distribution-methods), it will return the SDK version.


# Set Up Client Integration

Follow this step-by-step process to integrate ContextPush with your iOS app.

{% hint style="info" %}
ContextPush currently is only supported for iOS. If you’re interested in using ContextPush in other platforms or languages, contact us at <support@contextsdk.com>.
{% endhint %}

{% stepper %}
{% step %}

### Create a License Key & Install the SDK

Check out [Getting Started](/basics/getting-started) for details on how to create your license key and install the SDK.
{% endstep %}

{% step %}

### Import ContextSDK

{% code title="AppDelegate.swift" %}

```swift
import ContextSDK
```

{% endcode %}
{% endstep %}

{% step %}

### Ensure Necessary Entitlement

In your Xcode project, go to `Signing & Capabilities` and enable the `Background fetch`, `Background processing` and `Remote notifications` capabilities in the `Background Modes` section.
{% endstep %}

{% step %}

### Ensure Tasks are Registered

In your Xcode project, go to `Info` and add the `com.contextsdk.push.refresh-task` and `com.contextsdk.push.processing-task` to the list of `Permitted background task scheduler identifiers`.
{% endstep %}

{% step %}

### Enable ContextPush

In your `AppDelegate` or similar in the `applicationDidFinishLaunchingWithOptions` or equivalent call `ContextPush.applicationDidFinishLaunchingWithOptions()`. If everything worked you should see "\[ContextSDK] Successfully scheduled next check for ContextPush."

{% code title="AppDelegate.swift" %}

```swift
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    // Your existing app startup code

    ContextPush.applicationDidFinishLaunchingWithOptions()
}
```

{% endcode %}
{% endstep %}

{% step %}

### Understanding Background Wake-Up Behavior

{% hint style="warning" %}
**Important:** ContextPush uses background processing and silent push notifications to wake your app periodically in the background. This allows the SDK to assess the user's real-world context for optimal notification timing.
{% endhint %}

When ContextPush wakes your app in the background, iOS may call `application(_:didFinishLaunchingWithOptions:)` while your app is not in the foreground. This can have side effects if your app logic assumes this method only runs when the app is being launched by the user.

#### Recommended Approach: Use Lifecycle Notifications

The recommended way to handle foreground-specific logic is to use UIScene lifecycle methods or lifecycle notifications instead of checking the application state manually:

{% tabs %}
{% tab title="UISceneDelegate (Recommended)" %}
For apps using UIScene, implement `sceneDidBecomeActive(_:)` in your `UISceneDelegate`:

{% code title="SceneDelegate.swift" %}

```swift
func sceneDidBecomeActive(_ scene: UIScene) {
    // This is called when your app enters the foreground
    // Place foreground-specific initialization code here
}
```

{% endcode %}
{% endtab %}

{% tab title="UIApplication Notifications" %}
For apps not using UIScene, observe `UIApplication.didBecomeActiveNotification`:

{% code title="AppDelegate.swift" %}

```swift
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    // Your existing app startup code

    ContextPush.applicationDidFinishLaunchingWithOptions()

    // Observe when the app becomes active
    NotificationCenter.default.addObserver(
        self,
        selector: #selector(applicationDidBecomeActive),
        name: UIApplication.didBecomeActiveNotification,
        object: nil
    )

    return true
}

@objc private func applicationDidBecomeActive() {
    // This is called when your app enters the foreground
    // Place foreground-specific initialization code here
}
```

{% endcode %}
{% endtab %}

{% tab title="Manual State Check (Alternative)" %}
If you need to check the application state manually in `application(_:didFinishLaunchingWithOptions:)`:

{% code title="AppDelegate.swift" %}

```swift
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    // Your existing app startup code

    ContextPush.applicationDidFinishLaunchingWithOptions()

    // Only run foreground-specific code when app is in foreground
    if application.applicationState != .background {
        // Place foreground-specific initialization code here
    }

    return true
}
```

{% endcode %}
{% endtab %}
{% endtabs %}

#### Best Practices

* Move foreground-specific initialization logic (e.g., presenting onboarding screens, tracking app launches, or showing promotional content) to lifecycle methods that are specifically called when the app enters the foreground.
* Keep your `application(_:didFinishLaunchingWithOptions:)` implementation lightweight and suitable for both foreground and background launches.
* Test your app's behavior when woken up in the background to ensure no unexpected UI presentations or crashes occur.
  {% endstep %}

{% step %}

### Provide User ID and Push Token

You need to register the user's device with a unique user ID and the device's push token. The user-id may be whatever you use to identify each user in your existing push notification system, e.g. an email address or a user ID from your database.

Set the user ID with `ContextManager.setUserId(_:)`. The same user ID is shared across all ContextSDK products, so a single call associates the user with both your events and your push notifications. For push you then only need to provide the device token.

{% code title="AppDelegate.swift" %}

```swift
// Set the user ID once it is available, e.g. after the user signs in
ContextManager.setUserId(userId)

func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
    // Your existing code for handling device token registration

    // Provide the device token to ContextSDK to enable push notifications
    ContextPush.setDeviceToken(deviceToken)
}
```

{% endcode %}
{% endstep %}

{% step %}

### Handle Background Notifications

Ensure that your app can handle push notifications received while in the background by implementing the following method:

{% code title="AppDelegate.swift" %}

```swift
func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any],fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
    // ContextSDK will process the notification if it is a valid ContextPush notification
    // The completion handler will be automatically called if processed
    if ContextPush.applicationDidReceiveRemoteNotification(userInfo, fetchCompletionHandler: completionHandler) {
        return
    }
    // Handle other types of background notifications with your existing code, if needed
}
```

{% endcode %}
{% endstep %}

{% step %}

### Handle Notification Opens

Track when users open notifications to gather engagement metrics and improve user experience:

{% code title="AppDelegate.swift" %}

```swift
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
    // Process the notification with ContextSDK to track open rates and user interactions
    ContextPush.userNotificationCenterDidReceiveResponse(response)

    // Your existing code
    // Ensure completion handler is called to finalize notification handling
    completionHandler()
}
```

{% endcode %}
{% endstep %}

{% step %}

### (Optional) Customize Notification Content

If you need to customize notifications beyond what ContextPush supports by default, you can provide a callback to modify the notification content before it is displayed. This is useful for:

* Adding custom attachments or media
* Modifying text based on app state or localization
* Adding custom sounds
* Setting additional notification properties

{% code title="AppDelegate.swift" %}

```swift
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    // Your existing app startup code

    ContextPush.applicationDidFinishLaunchingWithOptions()

    // Optional: Customize notification content before display
    ContextPush.setNotificationContentCustomizer { content, userInfo in
        // Example: Add a custom subtitle based on app state
        content.subtitle = "Special offer just for you!"

        // Example: Add a custom sound
        content.sound = UNNotificationSound(named: UNNotificationSoundName("custom.caf"))

        // Return the modified content (or a new UNNotificationContent)
        return content
    }

    return true
}
```

{% endcode %}

The callback receives:

* `content`: The `UNMutableNotificationContent` built by ContextSDK from the push payload
* `userInfo`: The notification's user info dictionary, including your custom data

To disable customization, set the customizer to `nil`:

```swift
ContextPush.setNotificationContentCustomizer(nil)
```

{% endstep %}

{% step %}

### Test Your Setup

1. Visit your [dashboard](https://dashboard.contextsdk.com)
2. Select <kbd>**Integrate Push**</kbd> in the menu
   * If this option is still not available to you, request access in <kbd>**Products Info**</kbd> → <kbd>**ContextPush**</kbd>
3. Upload your push certificate and fill in the necessary information
   * This will grant you access to your API token, which will be used later on.
   * You can either create a new push certificate for ContextPush, or use your existing one.
4. Test your push setup by using the <kbd>**Test Push Integration**</kbd> feature
   {% endstep %}

{% step %}

### Integrate With Your Push Notification Provider

You're now done integrating with the iOS app. Next, integrate with your existing push provider:

<table data-view="cards"><thead><tr><th data-type="content-ref"></th></tr></thead><tbody><tr><td><a href="/pages/VSDsYP6FEgCKMAhWWlO7">/pages/VSDsYP6FEgCKMAhWWlO7</a></td></tr><tr><td><a href="/pages/2jbGUEMMRNMDXEM0OeCc">/pages/2jbGUEMMRNMDXEM0OeCc</a></td></tr><tr><td><a href="/pages/VLATnpvODQD4Zj4GDYrs">/pages/VLATnpvODQD4Zj4GDYrs</a></td></tr></tbody></table>

If you can't find your provider in this list, don't worry: you can integrate ContextPush with any provider, as long as it supports webhooks. Reach out to us to let us know which provider you use and we'll guide you through the integration steps.
{% endstep %}
{% endstepper %}

***

## Required: Notification Service Extension

ContextPush requires a Notification Service Extension to enable reporting when your app is not running.

{% content-ref url="/pages/dP8r0ScH8mIGG8onE2tU" %}
[Notification Service Extension](/context-push/notification-service-extension)
{% endcontent-ref %}


# Notification Service Extension

Use a Notification Service Extension to enable delivery tracking for ContextPush.

{% hint style="warning" %}
**Required for Delivery Tracking:** The Notification Service Extension is required for accurate delivery tracking. Setting up an App Group without implementing this extension will result in inaccurate delivery metrics.
{% endhint %}

## Overview

ContextPush requires a Notification Service Extension to track notification delivery when your app is not running. The extension allows iOS to wake a small piece of your code when a notification arrives, enabling delivery confirmation even when the main app is terminated.

**Benefits:**

* Accurate delivery tracking and metrics
* Context capture at notification delivery time
* Better analytics for notification effectiveness

## How Delivery Tracking Works

When you configure an `appGroupIdentifier` in your ContextSDK configuration, you are enabling delivery tracking. This tells ContextSDK that:

1. Your app has a Notification Service Extension set up
2. The extension will report traditional notification deliveries to ContextPush servers

{% hint style="danger" %}
**Important:** If you set an `appGroupIdentifier` but do not implement the Notification Service Extension, your traditional delivery tracking data will be inaccurate, or messages maybe delivered multiple times.
{% endhint %}

## Prerequisites

Before setting up the extension, ensure you have:

* Completed the basic [ContextPush integration](/context-push/integration)
* Xcode 14.0 or later
* iOS 16.0 deployment target or higher

***

## Step 1: Create App Group

App Groups enable data sharing between your main app and the extension. Both need access to the same license verification token and shared data.

{% hint style="info" %}
**App Group = Delivery Tracking Enabled:** When you configure an App Group in your main app's Configuration, ContextSDK enables delivery tracking. This is why completing **all steps** in this guide is essential—skipping the extension implementation will cause inaccurate metrics.
{% endhint %}

{% stepper %}
{% step %}

### Create an App Group Identifier

1. Go to the [Apple Developer Portal](https://developer.apple.com/account/resources/identifiers/list/applicationGroup)
2. Click the **+** button to create a new identifier
3. Select **App Groups** and click **Continue**
4. Enter a description and identifier (e.g., `group.com.yourcompany.yourapp`)
5. Click **Register**
   {% endstep %}

{% step %}

### Enable App Groups in Your Main App

1. Open your Xcode project
2. Select your **main app target**
3. Go to **Signing & Capabilities**
4. Click **+ Capability** and add **App Groups**
5. Check the app group you created (or click **+** to add it)
   {% endstep %}
   {% endstepper %}

***

## Step 2: Create Notification Service Extension

{% stepper %}
{% step %}

### Add Extension Target

1. In Xcode, go to **File** → **New** → **Target**
2. Select **Notification Service Extension**
3. Enter a name (e.g., `NotificationServiceExtension`)
4. Click **Finish**
5. If prompted to activate the scheme, click **Cancel** (you'll run the main app, not the extension directly)
   {% endstep %}

{% step %}

### Enable App Groups in Extension

1. Select your **extension target**
2. Go to **Signing & Capabilities**
3. Click **+ Capability** and add **App Groups**
4. Check the **same app group** you added to the main app

{% hint style="warning" %}
Both the main app and extension must use the exact same App Group identifier.
{% endhint %}
{% endstep %}

{% step %}

### Add ContextSDKExtension Dependency

{% tabs %}
{% tab title="CocoaPods" %}
Add the extension pod to your `Podfile`:

{% code title="Podfile" %}

```ruby
target 'YourApp' do
  pod 'ContextSDK'
end

target 'NotificationServiceExtension' do
  pod 'ContextSDKExtension'
end
```

{% endcode %}

Then run:

```bash
pod install
```

{% endtab %}

{% tab title="Swift Package Manager" %}

1. In Xcode, select your project in the navigator
2. Select the **extension target**
3. Go to **General** → **Frameworks, Libraries, and Embedded Content**
4. Click **+** and add `ContextSDKExtension` from the ContextSDK package
   {% endtab %}
   {% endtabs %}
   {% endstep %}
   {% endstepper %}

***

## Step 3: Configure Main App

Update your main app initialization to enable App Group support.

{% hint style="warning" %}
**Complete All Steps:** Adding the `appGroupIdentifier` to your Configuration enables delivery tracking. You **must** also complete Step 4 (Implement Extension) for accurate metrics. ContextSDK will log a warning if it detects an App Group configuration without a Notification Service Extension.
{% endhint %}

{% tabs %}
{% tab title="UIKit" %}
{% code title="AppDelegate.swift" overflow="wrap" %}

```swift
import ContextSDK

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

    // Configure ContextSDK with App Group support
    let config = Configuration(appGroupIdentifier: "group.com.yourcompany.yourapp")

    ContextManager.applicationDidFinishLaunchingWithOptions(
        launchOptions,
        licenseKey: "YOUR_LICENSE_KEY",
        configuration: config
    )

    // Continue with ContextPush setup...
    ContextPush.applicationDidFinishLaunchingWithOptions()

    return true
}
```

{% endcode %}
{% endtab %}

{% tab title="SwiftUI" %}
{% code title="AppDelegate.swift" overflow="wrap" %}

```swift
import ContextSDK

class AppDelegate: NSObject, UIApplicationDelegate {
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

        // Configure ContextSDK with App Group support
        let config = Configuration(appGroupIdentifier: "group.com.yourcompany.yourapp")

        ContextManager.applicationDidFinishLaunchingWithOptions(
            launchOptions,
            licenseKey: "YOUR_LICENSE_KEY",
            configuration: config
        )

        // Continue with ContextPush setup...
        ContextPush.applicationDidFinishLaunchingWithOptions()

        return true
    }
}
```

{% endcode %}
{% endtab %}
{% endtabs %}

{% hint style="info" %}
The `appGroupIdentifier` must match exactly what you configured in both targets' capabilities.
{% endhint %}

***

## Step 4: Implement Extension

Replace the contents of your `NotificationService.swift` with:

{% code title="NotificationService.swift" overflow="wrap" %}

```swift
import UserNotifications
import ContextSDKExtension

class NotificationService: UNNotificationServiceExtension {

    var contentHandler: ((UNNotificationContent) -> Void)?
    var bestAttemptContent: UNMutableNotificationContent?

    override func didReceive(
        _ request: UNNotificationRequest,
        withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
    ) {
        self.contentHandler = contentHandler
        bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)

        guard let bestAttemptContent = bestAttemptContent else {
            contentHandler(request.content)
            return
        }

        // Initialize ContextSDK for the extension
        let success = ContextManager.setupForExtension(
            appGroupIdentifier: "group.com.yourcompany.yourapp",
            licenseKey: "YOUR_LICENSE_KEY"
        )

        guard success else {
            // License verification failed - deliver notification without tracking
            contentHandler(bestAttemptContent)
            return
        }

            // Report notification and capture context
        // This call waits for context capture and server reporting before completing
        ContextPush.handleNotification(request.content.userInfo) {
            contentHandler(bestAttemptContent)
        }
    }

    override func serviceExtensionTimeWillExpire() {
        // iOS is about to terminate the extension - deliver what we have
        if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent {
            contentHandler(bestAttemptContent)
        }
    }
}
```

{% endcode %}

### How It Works

1. **`setupForExtension`** - Initializes the SDK using shared credentials from the App Group. Returns `false` if the main app hasn't been launched yet or license verification fails.
2. **`handleNotification`** - Reports the notification to ContextPush servers for accurate reporting.

### Tracking Additional Extension Events

You can also call `trackEvent`, `trackPageView`, and `trackUserAction` from the extension. In an extension these methods collect context asynchronously, so keep the extension alive until the callback runs. If you combine them with `handleNotification`, wait for both callbacks before calling `contentHandler`.

```swift
ContextManager.trackEvent("notification_received") {
    contentHandler(bestAttemptContent)
}
```

{% hint style="warning" %}
**Important:** The extension has limited execution time (\~30 seconds). `handleNotification` is designed to complete quickly, but always implement `serviceExtensionTimeWillExpire()` as a fallback.
{% endhint %}

***

## Troubleshooting

### Extension Not Running

* Verify App Groups are enabled in both targets
* Check that both targets use the same App Group identifier

### License Verification Fails

* Launch the main app at least once after adding App Group support
* Verify the license key is identical in both main app and extension
* Check the App Group identifier matches exactly

***

## Next Steps

* Review [Analytics & Reporting](/context-push/analytics-and-reporting) to understand your reporting metrics
* Check the [Release Checklist](/context-push/release-checklist) before going to production


# Set Up Message Scheduling

Set up message scheduling by integrating ContextPush with your existing message sending.

ContextPush was designed to be integrated with your existing push notification provider, making it quite flexible. For this reason, it integrates with pretty much any push notification provider, as long as it supports ongoing webhook integrations.

Read more about how to integrate with the provider your app currently uses:

* OneSignal
* Customer.io
* Simple Web Request (i.e. your own backend)


# OneSignal

Learn how to integrate ContextPush with OneSignal.

## Integrating ContextSDK Push Notifications with OneSignal Journeys

This guide provides step-by-step instructions on how to configure [OneSignal's Journeys](https://documentation.onesignal.com/docs/journeys-overview) to send push notifications using a webhook to interact with the ContextPush API.

### Step 1: Add a Webhook Action to the Journey

1. Go to the **Journey** you want to leverage context-aware push notifications.
2. **Add a new action** within your journey by selecting "Add Action" and then choose "Send Webhook."
3. **Configure the webhook** with the following details:
   * **URL**: `https://push.contextsdk.com/v1/campaigns/schedule_message_for_user`
   * **Request Type**: `POST`

### Step 2: Set Up Request Headers

In the webhook configuration, include the necessary headers to authenticate the request:

* **Authorization**: `Token [Your_Token]`
* **Content-Type**: `application/json`

<figure><img src="/files/grvsGloSbwHeO6yH9I2E" alt="" width="375"><figcaption></figcaption></figure>

### Step 3: Define the Request Body

Configure the webhook payload using OneSignal's templating to dynamically include user-specific data. Use the following JSON structure:

```json
{
  "bundle_id": "com.contextsdk.demo",
  "platform": "ios",
  "user_id": "{{ user.external_id }}",
  "content": {
    "title": "It's time to get started with crypto",
    "body": "Just upload your ID, and start trading now"
  },
  "deliver_within_hours": 6,
  "campaign_id": "daily-reminder",
  "delivery_method": "context_aware"
}
```

**Notes:**

* Replace `{{ user.external_id }}` with the appropriate OneSignal variable for the user ID.
* Adjust the `title` and `body` fields to match your campaign's messaging.

#### Using the Key/Value Pairs Body

If you prefer configuring your payload purely through the OneSignal UI we have a special endpoint that is compatible with a non nested JSON body. Instead of `https://push.contextsdk.com/v1/campaigns/schedule_message_for_user` use the `https://push.contextsdk.com/v1/campaigns/schedule_message_for_user_flattened` endpoint. This allows you to replace all nested values with keys in the format of `content/title`, `content/body`, etc.

This way you can fully configure ContextPush using only Key/Value pairs.

```json
{
  "bundle_id": "com.contextsdk.demo",
  "platform": "ios",
  "user_id": "{{ user.external_id }}",
  "content/title": "It's time to get started with crypto",
  "content/body": "Just upload your ID, and start trading now",
  "deliver_within_hours": 6,
  "campaign_id": "daily-reminder",
  "delivery_method": "context_aware"
}
```

<figure><img src="/files/upkLGTbRwHJa9q6kDqar" alt="" width="375"><figcaption></figcaption></figure>

#### Localization

You can use the built-in Liquid syntax to directly supply the localized string when scehduling a message. The below example uses the `https://push.contextsdk.com/v1/campaigns/schedule_message_for_user_flattened` endpoint:

```json
{%- assign userLang = user.language -%}
{
  "bundle_id": "com.contextsdk.internal",
  "platform": "ios",
  "user_id": "{{ user.external_id }}",
  "content/default_locale": "en",
  "content/title/en": "{% if userLang == 'de' -%} James, es ist Zeit für deinen Englischkurs! {%- else -%} James, it's time for your english course! {%- endif %}",
  "content/body/en": "{% if userLang == 'de' -%} Starte jetzt mit deinem Kurs! {%- else -%} Start course now because its a good time! {%- endif %}",
  "deliver_within_hours": 3,
  "campaign_id": "daily-reminder",
  "delivery_method": "context_aware"
}
```


# Customer.io

Learn how to integrate ContextPush with Customer.io.

## Integrating ContextSDK Push Notifications with Customer.io

This guide provides step-by-step instructions on how to set up and send push notifications using Customer.io's web request block to interact with the ContextPush API.

### Step 1: Add a Webhook Action

Go to where you want to leverage context-aware push notifications

1. **Add a new action** to your campaign and select "Webhook" from the list of available actions.
2. **Configure the webhook** with the following details:
   * **Request Type**: `POST`
   * **URL**: `https://push.contextsdk.com/v1/campaigns/schedule_message_for_user`

### Step 2: Set Up Request Headers

In the webhook configuration, set the request headers to include the necessary authorization token:

* **Authorization**: `Token [Your_Token]`

### Step 3: Define the Request Body

Configure the request body using Customer.io's templating language to dynamically include user-specific data. Use the following JSON structure:

```json
{
  "bundle_id": "com.contextsdk.demo",
  "platform": "ios",
  "user_id": "{{customer.email}}",
  "content": {
    "title": "It's time to get started with crypto",
    "body": "Just upload your ID, and start trading now"
  },
  "deliver_within_hours": 6,
  "campaign_id": "daily_reminder"
}
```

**Notes**:

* Replace `{{customer.email}}` with the appropriate Customer.io variable for the user ID. This example assumes the email is used as the user ID.
* Adjust the `title` and `body` fields to match your campaign's messaging.


# Simple Web Request

Learn how to integrate ContextPush using simple web requests.

## Generic Web Request

### API Token

To obtain your API token, please provide us with your push certificate. You can either create a new push certificate for us or use your existing one.

### Endpoint to send messages

`POST https://push.contextsdk.com/v1/campaigns/schedule_message_for_user`

This endpoint is used to schedule a push notification message for a specific user on the ContextSDK platform. You need to send one web request per message you want to send.

#### Headers

| Header        | Description                                                                                                       | Example Value                |
| ------------- | ----------------------------------------------------------------------------------------------------------------- | ---------------------------- |
| Authorization | The token used to authorize the request. Replace with a valid authentication token. Reach out to us to get access | `Token CTX-1234567890abcdef` |

#### Request Body

| Parameter              | Type                | Description                                                                                                                                                                                                                                                                  | Example Value                                        |
| ---------------------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
| bundle\_id             | String              | The unique identifier of the application bundle                                                                                                                                                                                                                              | `com.contextsdk.demo`                                |
| platform               | String              | The platform on which the user will receive the message                                                                                                                                                                                                                      | `ios`                                                |
| user\_id               | String              | The unique identifier of the user                                                                                                                                                                                                                                            | `user@contextsdk.com`                                |
| deliver\_within\_hours | Int                 | The time frame, in hours, within which the message should be delivered                                                                                                                                                                                                       | `6`                                                  |
| campaign\_id           | String              | The same type of message should always have the same `campaign_id`. This is used for performance insights, and to train custom models for each `campaign_id`.                                                                                                                | `daily-reminder`                                     |
| idempotency\_key       | String *(optional)* | A unique identifier for the push notification message. If the same `idempotency_key` is used in multiple requests, only the last request will be processed.                                                                                                                  | `1234567890abcdef`                                   |
| delivery\_method       | String *(optional)* | The delivery method for the message. Possible values are `context_aware` and `traditional`. If no value is specified, we will automatically do an AB test on your behalf. If you already have the AB test cohort for the user, you can specify the corresponding value here. | `context_aware`                                      |
| **content**            | Object              | Details of the message to be delivered. See subfields below.                                                                                                                                                                                                                 |                                                      |
| title                  | String              | The title of the push notification message.                                                                                                                                                                                                                                  | `James, It's time to learn English`                  |
| subtitle               | String *(optional)* | The subtitle of the push notification message                                                                                                                                                                                                                                | `Start your English course now!`                     |
| body                   | String              | The body content of the push notification message                                                                                                                                                                                                                            | `It's time to learn English. Start your course now!` |
| sound                  | String *(optional)* | The full name of the audio file in the root of the main app bundle                                                                                                                                                                                                           | `sosumi.caf`                                         |
| badge                  | Int *(optional)*    | The number to display as the badge of the app icon, 0 resets the badge                                                                                                                                                                                                       | `1`                                                  |
| userInfo               | Object *(optional)* | Custom data to be sent with the push notification message                                                                                                                                                                                                                    | `{ "key": "value" }`                                 |
| thread-id              | String *(optional)* | An app-specific identifier for grouping related notifications. This value corresponds to the `threadIdentifier` property in the `UNNotificationContent` object.                                                                                                              |                                                      |
| imageUrl               | String *(optional)* | A URL pointing to a image file hosted publicly on the internet. Requires setting up a notification service extension to fetch the image, see section below                                                                                                                   |                                                      |

#### Example Request

```
POST /v1/campaigns/schedule_message_for_user HTTP/1.1
Host: push.contextsdk.com
Authorization: Token CTX-123456789ABCD
Content-Type: application/json

{
    "bundle_id": "com.contextsdk.demo",
    "platform": "ios",
    "user_id": "user@contextsdk.com",
    "content": {
        "title": "James, it's time for your english course!",
        "body": "Start your english course now!"
    },
    "deliver_within_hours": 3,
    "campaign_id": "daily-reminder",
    "delivery_method": "context_aware"
}
```

**Alternative delivery window specification**

The above `deliver_within_hours` parameter specifies the time frame within which the message should be delivered from the current time onwards. If you want to specify a specific time frame within which the message should be delivered, you can use the `target_delivery_time` parameter instead. That parameter should be a string in the format ISO 8601, e.g. `2025-01-25T18:32:00Z`.

**Localization**

ContextPush has native support to localize messages for your users. The `content.title`, `content.body`, and `content.subtitle` fields support supplying a dictionary of translations instead of just a single string. ContextPush will then use the localization matching the locale the receiving user has set on their phone. A `content.default_locale` needs to be specified to determine which translation to use if none matches the users locale.

All translated fields need to specify all locales, meaning it's not supported to translate `title` and `body` in two different sets of languages.

**Example Request**

```
POST /v1/campaigns/schedule_message_for_user HTTP/1.1
Host: push.contextsdk.com
Authorization: Token CTX-123456789ABCD
Content-Type: application/json

{
    "bundle_id": "com.contextsdk.demo",
    "platform": "ios",
    "user_id": "user@contextsdk.com",
    "content": {
        "default_locale": "en",
        "title": {
          "en": "James, it's time for your english course!",
          "de": "James, es ist Zeit für deinen Englischkurs!"
        },
        "body": {
          "en": "Start your english course now!",
          "de": "Beginne jetzt mit deinem Englischkurs!"
        }
    },
    "deliver_within_hours": 3,
    "campaign_id": "daily-reminder",
    "delivery_method": "context_aware"
}
```

**Supporting images**

For context aware push notifications ContextSDK will handle fetching and setting the image on the notification before display. For notifications that are delivered traditionally (such as when explicitly using the traditional delivery mode, or in case there was no good moment during the delivery window) a `UNNotificationServiceExtension` needs to be used.

In your `UNNotificationServiceExtension` you can fetch the image and set it on the notification. Below is a full example for downloading and setting the image which can be integrated with your existing `UNNotificationServiceExtension`:

```
import UserNotifications

class NotificationService: UNNotificationServiceExtension {

    var contentHandler: ((UNNotificationContent) -> Void)?
    var bestAttemptContent: UNMutableNotificationContent?

    override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {

        self.contentHandler = contentHandler
        guard let bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent) else {
            contentHandler(request.content)
            return
        }
        self.bestAttemptContent = bestAttemptContent

        if let imageUrl = bestAttemptContent.userInfo["ctx_image_url"] as? String,
           let idempotencyKey = bestAttemptContent.userInfo["ctx_idempotency_key"] as? String,
            let fileUrl = URL(string: imageUrl) {
            let task = URLSession.shared.downloadTask(with: URLRequest(url: fileUrl)) { url, response, error in
                if let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) {
                    if let url = url,
                       let attachment = UNNotificationAttachment.saveImageToDisk(fileIdentifier: "\(idempotencyKey).jpg", sourcePath: url , options: nil) {
                        bestAttemptContent.attachments = [ attachment ]
                    }
                }
                contentHandler(bestAttemptContent)
            }
            task.resume()
        } else {
            contentHandler(request.content)
        }
    }

    override func serviceExtensionTimeWillExpire() {
        // Called just before the extension will be terminated by the system.
        // Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used.
        if let contentHandler = contentHandler, let bestAttemptContent =  bestAttemptContent {
            contentHandler(bestAttemptContent)
        }
    }
}

@available(iOSApplicationExtension 10.0, *)
extension UNNotificationAttachment {

    static func saveImageToDisk(fileIdentifier: String, sourcePath: URL, options: [NSObject : AnyObject]?) -> UNNotificationAttachment? {
        let fileManager = FileManager.default
        let folderName = ProcessInfo.processInfo.globallyUniqueString
        guard let folderURL = NSURL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(folderName, isDirectory: true) else {
            return nil
        }

        do {
            try fileManager.createDirectory(at: folderURL, withIntermediateDirectories: true, attributes: nil)
            let fileURL = folderURL.appendingPathComponent(fileIdentifier)
            try fileManager.moveItem(at: sourcePath, to: fileURL)
            let attachment = try UNNotificationAttachment(identifier: fileIdentifier, url: fileURL, options: options)
            return attachment
        } catch let error {
            print(error)
        }

        return nil
    }
}
```

**Note on traditional delivery**

When using the `traditional` delivery method, the notification will be sent to the user as soon as possible, usually with in at most 5 minutes. As such cancelling is only possible immediately after scheduling it.

### Endpoint to cancel delivery of a message

`POST https://push.contextsdk.com/v1/campaigns/cancel_message_for_user`

If a message is no longer applicable delivery can be cancelled if the message was not shown to the user yet. This requires you to set an idempotency key when scheduling the message. Cancelling a message is not possible if relying on the auto generated idempotency key.

#### Headers

| Header        | Description                                                                                                       | Example Value                |
| ------------- | ----------------------------------------------------------------------------------------------------------------- | ---------------------------- |
| Authorization | The token used to authorize the request. Replace with a valid authentication token. Reach out to us to get access | `Token CTX-1234567890abcdef` |

#### Request Body

| Parameter        | Type   | Description                                                                                                                                                   | Example Value         |
| ---------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- |
| bundle\_id       | String | The unique identifier of the application bundle                                                                                                               | `com.contextsdk.demo` |
| platform         | String | The platform on which the user will receive the message                                                                                                       | `ios`                 |
| user\_id         | String | The unique identifier of the user                                                                                                                             | `user@contextsdk.com` |
| campaign\_id     | String | The same type of message should always have the same `campaign_id`. This is used for performance insights, and to train custom models for each `campaign_id`. | `daily-reminder`      |
| idempotency\_key | String | A unique identifier for the push notification message. If the same `idempotency_key` is used in multiple requests, only the last request will be processed.   | `1234567890abcdef`    |

#### Example Request

```
POST /v1/campaigns/cancel_message_for_user HTTP/1.1
Host: push.contextsdk.com
Authorization: Token CTX-123456789ABCD
Content-Type: application/json

{
    "bundle_id": "com.contextsdk.demo",
    "platform": "ios",
    "user_id": "user@contextsdk.com",
    "campaign_id": "daily-reminder",
    "idempotency_key": "427c60db-c3df-4098-a0f7-9b5d153438f4"
}
```


# Device Classification

Understand how ContextPush classifies devices based on Background Operation Time eligibility.

## Overview

Context-aware delivery requires Background Operation Time (BOT), a capability that iOS grants only under specific system conditions. This is not a setting the app can toggle — it is a platform-level capability, similar to how CarPlay requires a supported vehicle or ProMotion requires specific hardware.

Because BOT is not available on all devices, ContextPush continuously monitors background behavior and classifies each device into one of three categories. This classification determines whether context-aware delivery can be used for a given user.

## Classification Categories

| Category       | API Value    | Description                                                                                                                          |
| -------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------ |
| **Eligible**   | `eligible`   | Background Operation Time is likely available on this device, enabling context-aware delivery.                                       |
| **Ineligible** | `ineligible` | Background Operation Time is likely not available on this device. Messages should be sent through your existing push infrastructure. |
| **Unknown**    | `unknown`    | Not enough data has been collected yet to make a prediction for this device.                                                         |

{% hint style="info" %}
Classification is a **continuous process**. Devices can move between categories as new data becomes available. For example, a device might transition from `unknown` to `eligible` once enough background activity has been observed.
{% endhint %}

All devices start as `unknown`. Typically, devices receive their first classification within the first day of SDK integration. Once the classification process has stabilized, you can expect roughly **30–40% of active devices** to be classified as `eligible`.

## How Classification Works

ContextPush uses background processing and silent push notifications to observe each device's background behavior over time. By analyzing ping patterns and device behavior, a statistical model determines whether the device can reliably execute tasks in the background — which is a prerequisite for context-aware delivery.

{% hint style="info" %}
Even for devices classified as `ineligible` or `unknown`, ContextPush continues to send lightweight background probes. This ensures that if a device's behavior changes (e.g., the user starts using the app more frequently), the classification will update accordingly.
{% endhint %}

## Recommended Integration

The classification system is designed to help you route messages through the optimal delivery path for each user:

1. **`eligible` devices** — Send messages through ContextPush for context-aware delivery.
2. **`ineligible` and `unknown` devices** — Send messages through your existing push infrastructure (e.g., Firebase, APNs, your CRM platform).

This approach ensures that every user receives their notification through the best available channel, while ContextPush focuses its optimization on the users where it will have the biggest impact.

### Typical Setup

{% stepper %}
{% step %}

#### Integrate the ContextPush SDK

Follow the standard [integration guide](/context-push/integration) to add the ContextPush SDK to your iOS app. The SDK handles all background probing and token registration automatically.
{% endstep %}

{% step %}

#### Configure a Device Transition Webhook

Provide us with a webhook URL and shared secret so we can notify your backend whenever a device's classification changes. See [Analytics & Reporting](/context-push/analytics-and-reporting#device-transition-webhook) for the webhook format.
{% endstep %}

{% step %}

#### Track Classification on Your Side

When you receive a device transition webhook, update the corresponding user attribute in your system (e.g., a `bot_status` field on the user profile) with the new classification value. The `bot_status` in the webhook payload refers to **B**ackground **O**peration **T**ime status.
{% endstep %}

{% step %}

#### Route Messages Based on Classification

When sending a push notification, check the user's current classification:

* If `eligible` → send through ContextPush
* If `ineligible` or `unknown` → send through your existing push infrastructure
  {% endstep %}
  {% endstepper %}

### Why This Matters

* **Maximize impact** — ContextPush delivers the most value for users whose devices support background execution. These tend to be your most engaged users who already drive the majority of your revenue and engagement.
* **Reduce risk** — By only routing eligible users through ContextPush, you avoid any chance of degraded delivery for users whose devices cannot support context-aware timing.
* **Keep control** — You maintain your existing sending logic and optimizations (e.g., message reordering, personalization) for users where ContextPush cannot add value.

## Device Transition Webhook

Whenever a device's classification changes, ContextPush sends a webhook notification to your configured endpoint. This allows you to keep your user attributes in sync in real time.

For full details on the webhook format, headers, and implementation notes, see:

{% content-ref url="/pages/dMrBy6wufkbLXnjII8Lp#device-transition-webhook" %}
[Analytics & Reporting](/context-push/analytics-and-reporting#device-transition-webhook)
{% endcontent-ref %}


# Release Checklist

Validate your ContextPush integration before releasing to the App Store or Google Play with this checklist.

{% stepper %}
{% step %}
**Ensure no ContextSDK errors are logged**

When running your app with ContextSDK installed on a real device, ensure that Xcode doesn't print out any ContextSDK related errors (search output for "ContextSDK")
{% endstep %}

{% step %}
**Verify background wake-up handling**

ContextPush wakes your app in the background using background processing and silent push notifications. Verify that your app properly handles background launches:

* Ensure `application(_:didFinishLaunchingWithOptions:)` doesn't present UI or perform foreground-only operations when called during background wake-ups
* Verify that foreground-specific logic (onboarding, promotional screens, analytics events) is properly gated using UIScene lifecycle methods (`sceneDidBecomeActive(_:)`), `UIApplication.didBecomeActiveNotification`, or application state checks
* Review your app's initialization code to confirm it handles both foreground and background launches appropriately

See the [Understanding Background Wake-Up Behavior](/context-push/integration#understanding-background-wake-up-behavior) section in the integration guide for implementation details.
{% endstep %}

{% step %}
**Ensure `Remote Notification` entitlement**

In your Xcode project, go to `Signing & Capabilities` and enable the `Remote Notification` capability in the `Background Modes` section.
{% endstep %}

{% step %}
**Verify Notification Service Extension**

ContextPush requires a [Notification Service Extension](/context-push/notification-service-extension) for accurate delivery tracking:

* Verify both main app and extension have the same App Group identifier in capabilities
* Confirm `ContextSDKExtension` pod/package is added to the extension target
* Test that `setupForExtension` returns `true` after launching main app
* **Important:** If you configured `appGroupIdentifier` in your main app but haven't set up the extension, there maybe duplicated deliveries
  {% endstep %}

{% step %}
**Monitor Context Dashboard**

Once your update goes live, head over to [dashboard.contextsdk.com](https://dashboard.contextsdk.com) to monitor your app's insights. Even though our technology is real-time, many of the graphs only really make sense once a certain amount of data has been collected.

The first graphs & insights will be shown, once your app update was released to the App Store, and enough users have updated to the new version.

We will also notify you via email, once the first insights are ready.
{% endstep %}
{% endstepper %}


# Analytics & Reporting

Learn how to track ContextPush metrics.

There are two ways to get data on the performance of your ContextPush integration:

1. We will prepare reports on measured open rates for all running campaigns and share them with you on a regular basis.
2. You can integrate with our Webhook solution to track performance on your end, see next section.

## Webhooks

To get started with ContextPush webhooks reach out to us under <support@contextsdk.com> and provide us with a URL where we should push the webhook events to.

For the initial setup you only need to share a secret with us that can be used to verify that the webhooks are coming from our service.

ContextPush supports two types of webhooks:

* **Message Status Webhooks** — Notify you about delivery status changes for individual messages (e.g., delivered, opened, cancelled).
* **Device Transition Webhooks** — Notify you when a device's [classification](/context-push/device-classification) changes (e.g., from `unknown` to `eligible`).

### Common Headers

All webhooks include the following headers:

| Header              | Description                                                                                                                                                         |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `X-CTX-Secret`      | Your shared secret. Verify this before processing the request.                                                                                                      |
| `X-CTX-Event-Type`  | The event type — `message_status` for delivery updates, `device_transition` for classification changes.                                                             |
| `X-Idempotency-Key` | A unique key for the entire webhook HTTP request. This is distinct from the `idempotency_key` field inside each payload item, which identifies an individual event. |

## Message Status Webhook

### Format

The webhook request will be sent to you in the following format:

```
POST /your_webhook_endpoint
Host: your.host
X-CTX-Secret: your_shared_secret
X-CTX-Event-Type: message_status
X-Idempotency-Key: idempotency_key
Content-Type: application/json

[
  {
    "device_token": "push_token",
    "user_id": "your_user_id",
    "campaign_id": "your_campaign_id",
    "idempotency_key": "idempotency_key",
    "delivery_status": "opened",
    "delivery_method": "context_aware",
    "planned_delivery_method": "context_aware",
    "timestamp": "2025-02-07T15:20:40.599Z",
    "content": {
      "title": "James, it's time for your English course! 👑",
      "body": "Start your English course now!"
    }
  },
  …
]
```

Each request can contain multiple entries, the maximum being 100.

To illustrate the format here is the TypeScript type definition:

```ts
type DeliveryStatus = 'failed' | 'cancelled' | 'opened' | 'delivered' | 'pending';
type DeliveryMethod = 'context_aware' | 'traditional' | 'cancelled';
type PlannedDeliveryMethod = 'context_aware' | 'traditional';

// A single message status event
export type MessageStatusEvent = {
  device_token: string | undefined; // Will be undefined when the delivery_status is cancelled.
  user_id: string;
  campaign_id: string;
  idempotency_key: string;
  delivery_status: DeliveryStatus;
  delivery_method: DeliveryMethod;
  planned_delivery_method: PlannedDeliveryMethod;
  timestamp: string; // ISO 8601 Timestamp
  content: MessageContent; // Copied 1:1 from what you supplied when scheduling the message.
};

// The webhook request body is an array of events
export type MessageStatusWebhookPayload = MessageStatusEvent[];
```

Your secret will be sent in the `X-CTX-Secret` header and should be verified by you before processing the request.

### Attaching Extra Information

Since the full content when scheduling the notification is echoed back to you you can use the `content.userInfo` property to attach any data you might require during later processing. Simply set it when scheduling the notification.

### Explanation of Delivery Status

* `pending` - This notification has not been sent yet, the user has not seen this notification.
* `delivered` - The APNS servers confirmed that they successfully accepted the request to send the notification, and it most likely was shown to the user. ContextPush currently does not implement confirmed deliveries using a `UNNotificationServiceExtension`.
* `opened` - The user has clicked on this notification.
* `cancelled` - A request was sent to cancel this notification from being delivered, this status guarantees that the user will not have seen this notification.
* `failed` - The notification could not be sent because the `device_token` was no longer valid.

### Explanation of Delivery Methods

* `context_aware` - This notification was delivered using ContextPush as a context-aware notification, in a good moment.
* `traditional` - This notification was delivered as a traditional push notification, without considering the users real-world context.

### Identifying a Single Message

Depending on if you care about delivery per user, or per device to uniquely define a single message the combination of: `idempotency_key`, `campaign_id`, `user_id` and optionally `device_token` will be unique. Consider that after 30 days messages are deleted from our system so the key may be reused at that time.

### Other Implementation Notes

Due to the nature of distributed systems we can only guarantee at-least-once delivery of the final state change, you might not receive all intermediary state changes, and we cannot guarantee the order e.g. you might receive a `delivered` event after an `opened` event for the same message, and even the same device.

ContextPush will retry delivery of failed webhooks up to 10 times over a 2h window, after that they are dropped.

## Device Transition Webhook

Device transition webhooks notify your backend whenever a device's [classification](/context-push/device-classification) changes. This allows you to keep a user attribute (e.g., `bot_status`) in sync so you can route messages through the optimal delivery path.

{% hint style="info" %}
For background on what device classification is and how to integrate with it, see [Device Classification](/context-push/device-classification).
{% endhint %}

### Format

The webhook request will be sent to you in the following format:

```
POST /your_webhook_endpoint
Host: your.host
X-CTX-Secret: your_shared_secret
X-CTX-Event-Type: device_transition
X-Idempotency-Key: idempotency_key
Content-Type: application/json

[
  {
    "device_token": "push_token",
    "user_id": "your_user_id",
    "old_bot_status": "unknown",
    "new_bot_status": "eligible",
    "timestamp": "2025-02-07T15:20:40.599Z",
    "idempotency_key": "550e8400-e29b-41d4-a716-446655440000"
  },
  …
]
```

Each request can contain multiple entries, the maximum being 50.

To illustrate the format here is the TypeScript type definition:

```ts
type BotStatus = 'eligible' | 'ineligible' | 'unknown';

// A single device transition event
export type DeviceTransitionEvent = {
  device_token: string;
  user_id: string;
  old_bot_status: BotStatus;
  new_bot_status: BotStatus;
  timestamp: string;  // ISO 8601 Timestamp
  idempotency_key: string;
};

// The webhook request body is an array of events
export type DeviceTransitionWebhookPayload = DeviceTransitionEvent[];
```

### Field Reference

BOT in `old_bot_status` / `new_bot_status` stands for **Background Operation Time** — the iOS capability required for context-aware delivery.

| Field             | Description                                                                                              |
| ----------------- | -------------------------------------------------------------------------------------------------------- |
| `device_token`    | The APNs device token identifying the device.                                                            |
| `user_id`         | The same user ID you use when scheduling messages through ContextPush (set via `ContextPush.setUserId`). |
| `old_bot_status`  | The device's previous BOT classification (`eligible`, `ineligible`, or `unknown`).                       |
| `new_bot_status`  | The device's new BOT classification.                                                                     |
| `timestamp`       | When the transition was detected (ISO 8601).                                                             |
| `idempotency_key` | A unique key for this transition event, use it to deduplicate on your end.                               |

### Explanation of Classification Values

* `eligible` — Background Operation Time is likely available on this device, enabling context-aware delivery.
* `ineligible` — Background Operation Time is likely not available on this device. Messages should be sent through your existing push infrastructure.
* `unknown` — Not enough data has been collected yet to classify this device. This is the initial state for all new devices.

### Implementation Notes

* A webhook is only sent when a device **changes** classification. You will not receive webhooks for devices that remain in the same category.
* Due to the nature of distributed systems we can only guarantee at-least-once delivery. Use the `idempotency_key` on each transition item to deduplicate events on your end.
* **Respect the `timestamp` field when applying updates.** Events may arrive out of order. Store the `timestamp` alongside the current BOT status on your end and only apply an incoming transition if its `timestamp` is newer than the one you have stored.
* ContextPush will retry delivery of failed webhooks up to 10 times over a 2h window, after that they are dropped.


# Golden Signals

Read ContextSDK's on-device Golden Signals to enrich ad requests, either instantly or after waiting for a full sensor window.

Golden Signals are a snapshot of the device's current context - motion, device state, and the outputs of ContextSDK's on-device models - returned as a plain dictionary you can attach to an ad request. Everything is computed on device.

{% hint style="info" %}
Golden Signals are only available in an AdTech build of ContextSDK, distributed per ad network. If `goldenSignals` doesn't exist on `ContextManager` in your integration, you're on a standard build - talk to us and we'll get you the right one.
{% endhint %}

## Choosing between the two methods

There are two ways to read the signals, and the difference is only in how they treat the motion sensor:

<table><thead><tr><th width="230">Method</th><th>Behaviour</th></tr></thead><tbody><tr><td><code>goldenSignals()</code></td><td>Returns immediately, reading whatever the accelerometer has buffered at that instant.</td></tr><tr><td><code>fetchGoldenSignals()</code></td><td>Waits until a full sensor window has been collected, then calls you back.</td></tr></tbody></table>

The distinction matters because the accelerometer buffer starts out empty **at every app launch and every return to the foreground**, and takes a few seconds to fill. Ad requests tend to cluster in exactly that window - interstitial and rewarded preloads usually fire moments after launch or resume - so `goldenSignals()` called there returns a thinner dictionary, with motion-derived signals and any model outputs that depend on them missing or based on a partial window.

Use `goldenSignals()` when you cannot afford to wait at all. Use `fetchGoldenSignals()` when you would rather wait a moment for complete data - it is the better default for a bid request that isn't already at its deadline.

## Reading the signals instantly

{% tabs %}
{% tab title="Swift" %}

```swift
let signals = ContextManager.goldenSignals(
    trigger: "before_bid_request",
    bidID: "your-bid-id"
)
// Attach `signals` to your ad request
```

{% endtab %}
{% endtabs %}

Every parameter is optional - see [Tagging the request](#tagging-the-request).

## Waiting for a full sensor window

`fetchGoldenSignals()` calls your closure once, on the main thread, with a status and the signals:

{% tabs %}
{% tab title="Swift" %}

```swift
ContextManager.fetchGoldenSignals(
    trigger: "before_bid_request",
    bidID: "your-bid-id"
) { status, signals in
    // A dictionary always comes back - attach it to your ad request
    if status != .ok {
        // Optional: note that this request went out on incomplete sensor data
    }
}
```

{% endtab %}
{% endtabs %}

The status tells you how complete the sensor data behind the dictionary is:

<table><thead><tr><th width="200">Status</th><th>Meaning</th></tr></thead><tbody><tr><td><code>.ok</code></td><td>A full sensor window was collected. This is the case the method exists to guarantee.</td></tr><tr><td><code>.timeout</code></td><td>The call ran out of time before the sensor delivered a full window. The dictionary holds the instantly-available data instead - the same thing <code>goldenSignals()</code> would have returned.</td></tr><tr><td><code>.sensorUnavailable</code></td><td>The device has no usable motion sensor, or you're running in the simulator. Motion signals are flagged invalid and the model-driven results that depend on them are absent.</td></tr></tbody></table>

{% hint style="info" %}
A dictionary always comes back - the call never returns empty-handed, and never blocks indefinitely. If you have no use for the distinction, you can ignore the status entirely and treat the method as a drop-in for `goldenSignals()`.
{% endhint %}

What a non-`.ok` status does mean is that the dictionary is **less complete**. Anything derived from motion needs a usable window to be computed from, so on `.timeout` and `.sensorUnavailable` the motion signals and the model-driven results that consume them (`u_`, `m_` and `mv_` keys) may be based on a partial window or missing altogether. Read keys defensively rather than assuming a fixed set - which is good practice anyway, since the exposed set is configured remotely.

The call is bounded: it gives up after **at most 10 seconds** and reports `.timeout`, so it will never block an ad request indefinitely.

{% hint style="warning" %}
A call made while your app is in the background cannot collect fresh motion samples and will report `.timeout` once that budget elapses. Read Golden Signals from the foreground.
{% endhint %}

## Tagging the request

Both methods take the same optional parameters, and all of them are worth setting:

* **`trigger`** - where in your flow you're reading the signals, for example `"before_bid_request"` or `"before_ad_show"`. Use a distinct value per call site.
* **`bidID`** - your identifier for the bid. Setting it lets us connect the several contexts captured across one bid's lifecycle, which is what makes stage-by-stage analysis possible.
* **`customSignals`** - anything you know about this request that we can't measure on device. See [Attaching custom signals](#attaching-custom-signals).

The dictionary also comes back with a `ctxId` key: a short identifier for this specific context. Keep it alongside your own records if you want to join your reporting back to a single Golden Signals read.

## Attaching custom signals

Both methods accept custom signals for that one call - the ad format you're about to request, how many impressions this session has already shown, whatever else describes the request:

{% tabs %}
{% tab title="Swift" %}

```swift
let customSignals: [CustomSignal] = [
    CustomSignalString(id: "ad_format", value: "rewarded"),
    CustomSignalInt(id: "session_impressions", value: 4),
]
let signals = ContextManager.goldenSignals(
    trigger: "before_bid_request",
    bidID: "your-bid-id",
    customSignals: customSignals
)
```

{% endtab %}
{% endtabs %}

These are layered on top of any global signals you've set with `setGlobalCustomSignal(id:value:)`, and a per-call signal wins over a global one with the same id. See [Custom Signals](/context-decision/advanced/custom-signals) for the rules an id and value must follow, and for how to set globals.

They are used in two places: they're recorded on the context event we log for this call, so your bid data and ours can be joined on `ctxId`, and they're available to the on-device models as features - so a model trained on one of your custom signals can act on it.

{% hint style="warning" %}
Custom signals must not contain personally identifiable information - no user ids, email addresses, phone numbers, IP addresses or exact locations. The same rules apply here as everywhere else in ContextSDK.
{% endhint %}

## What's in the dictionary

Keys are stable strings and values are heterogeneous, so read each key as the type that signal carries:

<table><thead><tr><th width="190">Key pattern</th><th>Contents</th></tr></thead><tbody><tr><td><code>ctxId</code></td><td>A <code>String</code> identifying this context.</td></tr><tr><td><code>contextDuration</code></td><td>An <code>Int</code>: the sensor window length in seconds, when enabled for your app.</td></tr><tr><td><code>c&#x3C;number></code></td><td>An individual signal. The type follows the signal - <code>Bool</code>, <code>Int</code>, <code>Float</code>, <code>Double</code>, <code>Decimal</code>, <code>String</code>, <code>[String]</code> or <code>[Float]</code>.</td></tr><tr><td><code>u_&#x3C;name></code></td><td>A <code>Float</code> probability from an on-device activity model.</td></tr><tr><td><code>m_&#x3C;name></code></td><td>An on-device model output: a <code>Double</code> score, or a <code>[Float]</code> vector for a moment embedding.</td></tr><tr><td><code>mv_&#x3C;name></code></td><td>A <code>String</code> version of the model that produced the matching <code>m_</code> embedding.</td></tr></tbody></table>

Keys that aren't available for a given call are omitted rather than returned empty, so check for a key's presence rather than assuming a fixed set. Which signals and model outputs are exposed is configured remotely per app, so the set can change without an app update.

{% hint style="warning" %}
A moment embedding is a model's unmodified output, so individual elements may be `NaN` or infinite. Filter them if your pipeline can't represent them - in particular, `JSONSerialization` rejects non-finite values.
{% endhint %}


# Multivariate Monetization

Ad or paywall? Let AI fine-tune your strategy for higher revenue.

Your app likely presents users with different monetization paths: rewarded ads, interstitials, subscriptions, or paywalls. Each path brings different amount of revenue, depending on when and how it’s shown. ContextDecision uses machine learning decide which path will be the most profitable one, dynamically, based on the user's real-life context.

## Who is this for?

This strategy is ideal if your app already supports multiple monetization methods. The most common pairing is ad monetization alongside in-app purchases — such as a premium subscription or a one-time purchase to remove ads.

If that sounds familiar, you’re in luck. Replacing your ad logic with ContextDecision’s multivariate monetization unlocks a new stream of potential revenue with little effort and huge potential!

## How does it work?

Our ML model gets to decide which action has better chances of generating overall more revenue for you, for instance, watching an ad (a certain but low revenue action), or seeing an upsell (an uncertain but higher revenue action).

This allows the ML model to learn which contexts lead to revenue, and to prefer higher-yielding strategies in the future.

Based on our data, this has proven to be more profitable than always showing an ad, and doesn't increase user churn as showing an ad followed by a paywall would.

## How can I implement this?

1. If you haven't integrated ContextDecision into your app yet, head to [Getting Started](/basics/getting-started)
2. Next, start [Logging Conversions](/context-decision/logging-conversions) for the flow that ContextDecision will decide which monetization path to take
   1. When receiving the `context` object, the `shouldUpsell` boolean property indicates which monetization strategy should be taken, for example, when `true`, your app should display a paywall, and when `false`, display an ad.
3. We recommend having a cooldown mechanism if one of your monetization paths is showing a paywall, because capturing contexts close enough to each other might result in both being good moments, showing the paywall twice in a row. So a mechanism that limits the amount of paywalls per unit of time is ideal.
4. Now, when logging your outcomes, there are a few points of attention:
   1. There's no `skipped` outcome anymore, because you're now showing something to the user when `shouldUpsell` is both `true` and `false` .
   2. Before logging your outcome, log which path your app ended up taking by using a [Custom Outcome Metadata](/context-decision/advanced/custom-outcome-metadata), such as `"path": "ad"` and `"path": "paywall"` , in the example of a "ad vs paywall" flow.
      1. This is especially critical in case your cooldown mechanism changes the decision our ML models made, e.g. `shouldUpsell = true` but you've just showed a paywall, so you end up going with the ad path.
5. Logging [Revenue Outcomes](/context-decision/revenue-outcomes) becomes even more important. Since the ML model will be optimized for revenue, it's important for it to know how much revenue your ad interstitial generates, versus your paywall sales, for example.

## Best Practices

* **Track revenue whenever your flow generates income.**\
  If you can’t measure revenue precisely, use estimates — such as average eCPM (effective cost per mille) for ad impressions or average RPC (revenue per click) for ad-driven flows. These values help compare the relative performance of different monetization paths. Of course, the more accurate your data, the better the results.
* If your one of the paths of your flow is an ad banner, see [Inline Banners](/use-cases/inline-banners) for more tailored instructions.


# (Coming Soon) Reengagement

Device-side signals: unlock a new paradigm of parameters to power your push notifications.

The documentation is coming soon, but our product already supports this.

Reach out to our team to learn more.


# (Coming Soon) Abandoned Onboarding

Prevent churn and have users finish onboarding when they're most willing to do so.

The documentation is coming soon, but our product already supports this.

Reach out to our team to learn more.


# Inline Banners

Learn how to properly log conversions for ad banners and offers that are embedded in the UI.

Apps often have inline elements — whether in the form of monetized ad banners, a card promoting a feature, or a discount offer, etc. — that blends in in the UI, without interrupting users like pop ups do. The challenge with logging conversions for these inline elements is that they not always have clear "outcomes", as users might simply never interact with them. Here is where [Alternative Outcomes](/context-decision/advanced/alternative-outcomes) really come in handy.

## Dynamically Hiding The Banner

In this section, let's use the example of an inline banner upselling a subscription that shows up while the user is scrolling a feed, like this:

<figure><img src="/files/GDXxkTyfQpYXjla5xTms" alt="" width="188"><figcaption></figcaption></figure>

The goal is to prevent the banner from being displayed when users are unlikely to interact with it.

### Capturing Context

The right moment to capture the context is when you're about to present it. In this case of a scrolling view, it should be right before it gets rendered on the screen.

{% hint style="info" %}
Visit [Logging Conversions](/context-decision/logging-conversions#capturing-context) if you need a refresher on how to capture context.
{% endhint %}

### Logging Conversions

After capturing the context, check the context's `shouldUpsell` property to see if your banner should be displayed or not. If not, log `skipped`, as usual. If it ends up being displayed, you'll need to log different types of outcomes, depending on the user's interactions:

* `negativeNotInteracted`: at a suitable later point, such as when the user closes the app or exits the screen displaying the banner. In our example, it will be when the user scrolls past the banner and the banner gets un-rendered.

  To handle this, use the `context.logIfNotLoggedYet` method, e.g.:

  ```swift
  func applicationDidEnterBackground(_ application: UIApplication) {
      guard let context = ContextManager.recentContext(flowName: "onboarding_upsell") else { return }
      context.logIfNotLoggedYet(.negativeNotInteracted)
  }
  ```
* `positiveInteracted`: when the user taps on the banner or its CTA, opening its paywall, for instance.
* `negativeDismissed`: when the user clicks on the 🅧 button to remove the banner from the screen.
* `negative`: when the user dismisses the paywall (they clicked because they were curious but didn’t purchase anything and dismissed it).
* `positive`: when the user purchases something inside the paywall. Log this using [Revenue Outcomes](/context-decision/revenue-outcomes).

For more details about these outcome types, see [Alternative Outcomes](/context-decision/advanced/alternative-outcomes).

## Multivariate Monetization

If your app is using a [Multivariate Monetization](/use-cases/multivariate-monetization) strategy, the only point of attention is that when `shouldUpsell` is `false` , instead of logging `skipped` and exiting early, you will present the ad banner and log the outcomes as explained in the section above [#logging-conversions](#logging-conversions "mention").

{% hint style="info" %}
Before logging your outcome, don't forget to log which path your app ended up taking by using a [Custom Outcome Metadata](/context-decision/advanced/custom-outcome-metadata), such as `"path": "ad"` and `"path": "paywall"`.

For more information, see [Multivariate Monetization](/use-cases/multivariate-monetization).
{% endhint %}


# \[WIP] Experiment: From Ad Flow to Multivariate Monetization

This article covers a step-by-step guide on how to implement a multivariate monetization flow experiment, assuming you already have ads implemented in your app.

1. Before displaying your ad, fetchContext
2. Check shouldUpsell. If true, show your paywall. If false, show your ad.
   1. Fullscreen Ad
   2. Ad banner

## WIP


# Glossary

A quick reference guide to key SDK terms like events, outcomes, flows, and more.

This page contains a comprehensive reference for key terms used in our SDK.

This page provides clear and concise definitions for technical concepts and jargon, helping you understand and effectively implement our features. Whether you’re new to the SDK or need a quick refresher, this is your go-to guide for terms like *outcomes,* *entry points*, and *user-initiated flows*.

### A/B Test Split

When ContextSDK deploys a machine learning model to your app, users are divided into two groups: **Control** and **Treatment**. This ensures accurate performance comparisons.

#### Treatment Group

Users in this group experience the ML model’s effects. Their app experience is influenced by ContextSDK’s decisions, such as seeing upsell offers at optimal moments, receiving push notifications when they’re most relevant, etc.

This group is also commonly referred to as the **ContextSDK Group**.

#### Control Group

Users in this group serve as a baseline. They experience your app as it was before integrating ContextSDK, without any influence from the ML model.

By comparing these groups under the same conditions, you can determine whether behavior changes are due to ContextSDK and measure improvements in conversions, sales, revenue, or other success metrics.

### Calibration Phase

During calibration phase, ContextSDK gathers interaction data to refine its machine learning model for your application. This phase continues until approximately 1,000 positive user interactions have been collected for a given flow. Once sufficient data is collected, ContextSDK transitions to optimized decision-making, ensuring that in-app prompts are delivered at the most effective moments.

Also, during the calibration phase, your app behaves the exact same way as it used to. If you are also adding a new [#activation-opportunity](#activation-opportunity "mention"), we will disable the it during the calibration phase. For more information, see [Adding Entry Points](/context-decision/adding-entry-points#disabling-new-activation-opportunities-during-calibration-phase).

### Custom Signal

Custom signals allow you to provide additional context when capturing user interactions. ContextSDK processes more than 300 built-in signals, but you can enhance its predictions by including app-specific data — such as in-game progress, friend count, or prior interactions like ad views or feature usage. Any past event that may influence a user’s decision-making process is valuable to include.

{% hint style="warning" %}
Important: Never send personally identifiable information (PII) to the SDK. See [Privacy Considerations](https://docs.contextsdk.com/context-decision/advanced/custom-signals#privacy-considerations) to learn more.
{% endhint %}

See [Custom Signals](/context-decision/advanced/custom-signals) to learn more.

### Entry Points

Entry points are triggers in your app that may show an upsell to your users.

In those moments, your app invokes ContextSDK to capture and analyze the user’s context.

These contexts allow the ML model to determine the next action - such as show an upsell screen, an ad, an app rating prompt, or skip the flow completely. The actions are tailored to your app’s specific use case.

<figure><img src="/files/M1tUnZ0uygQ6ZDJebMgg" alt=""><figcaption><p>Examples of entry points in an app that offers subscriptions</p></figcaption></figure>

See [Adding Entry Points](/context-decision/adding-entry-points) to learn more.

### Event

A moment being captured in the user's journey, which includes the user's context. See also [Capturing Context In Key Moments](/advanced/capturing-context-in-key-moments).

### Experiment

Also known as [#project](#project "mention").

### Flow

When capturing a context, you’ll need to define a *flow name*, which acts as the context’s unique identifier for internal differentiation. A single user journey may include multiple distinct flows.

Each flow you track must include both [#positive-outcome](#positive-outcome "mention") and [#negative-outcome](#negative-outcome "mention"). If either is missing, the flow is considered incomplete and may not function as intended.

### Model

This refers to a machine learning (ML) model. It's the artifact produced by our team based on the context alongside the outcomes collected. Models enable ContextSDK to make decisions about which actions to take in your app.

### Outcome

When optimizing a flow, you focus on achieving a specific goal. For example, if you’re improving the conversion rate, you might track a conversion as a positive outcome and track the offer being dismissed as a negative outcome.

If an outcome isn’t logged for a given context, the ML model can’t be trained to improve the prompt. Without this data, it’s unclear whether the user converted after the context was captured.

#### Positive outcome

A desired result that indicates success, such as a user completing a purchase or signing up.

#### Negative outcome

An undesired result, such as a user closing an offer or abandoning the flow.

### Project

A project, previously referred to as an experiment, represents a flow within your app and defines a specific goal. For example, you might create a project to:

* Increase revenue from the post-onboarding upsell prompt
* Reduce push notification opt-out rates
* Improve conversion rates for app rating prompts

Your app may have multiple projects, as needed. Each project consists of one or more flows (see [#flow](#flow "mention")), and for each flow, a single [#model](#model "mention") is active. The active model may be the same across flows or differ for each flow.

### Prompt Intensity

When training a machine learning model for your flow, its goal is to determine whether to display an upsell offer based on the user’s context. The model generates a score between 0 and 1, where 0 represents the least favorable moment to show the offer, and 1 represents the most favorable.

Prompt intensity controls how often an offer appears by setting a threshold for the best moments. The intensity is expressed as a percentage, for example:

* At 30%, the offer appears only in the top 30% of the best moments:
* At 50%, the offer is shown only in the top 50% of the most favorable moments:

A lower intensity results in fewer but more optimally timed prompts, while a higher intensity increases the likelihood of displaying the prompt across a broader range of moments.

### Upsell Offer

This is a visual prompt (e.g. an alert, a modal, a sheet, etc.) shown in your app that offers something, usually a premium subscription, a consumable, an app rating request, or an app permission request. What is shown depends on your app's specific goals and monetization strategies.

### Upsell Prompt

Also known as [#upsell-offer](#upsell-offer "mention").

### User-initiated flows vs chance-initiated flows

#### User-Initiated Flows

User-initiated flows are actions intentionally taken by users that require immediate on-screen feedback. For example, when a user navigates to their profile and selects “Purchase Pro Plan”, they expect a subscription screen to appear instantly. In such cases, you can capture the context using the *calibrate* method. However, decision-making isn’t applied here, as it could lead to inaction — such as nothing happening when the button is pressed — if the ML model determines it’s a “bad moment.”

#### Chance-Initiated Flows

Chance-initiated flows, on the other hand, are triggered by chance or predefined mechanisms rather than user actions. Examples include:

* Displaying an ad every five game levels completed.
* Showing an upsell screen on app launch for users without an active subscription.
* Suggesting an upgrade after the user completes a key positive action in the app.

In these scenarios, users aren’t actively seeking to open the associated screens. This means that if the SDK decides not to show a prompt during a “bad moment,” users remain unaware, preserving their experience.


# Updating Your SDK

Learn how to update the SDK for each platform your app supports.

The latest ContextSDK version is 5.17.0 and was released on August 14, 2026.

{% tabs %}
{% tab title="iOS" %}
{% tabs %}
{% tab title="Swift Package Manager (SPM)" %}
SPM has a few issues when it comes to caching its dependencies, so please be sure to follow all the steps below:

* Trigger an update of SPM using your preferred method ( <kbd>Right Click</kbd> → <kbd>Update Package</kbd> or <kbd>Swift Packages</kbd> → <kbd>Update to Latest Package Versions</kbd>)
* Clear Derived Data
* Clean Build
* Run the app again, and verify `ContextManager.sdkVersion()` is the latest version.

Alternatively, you can also specify the specific version of ContextSDK you want to use in your `Package.swift` or Xcode SPM settings.
{% endtab %}

{% tab title="CocoaPods" %}
Run `pod update`, or if you specified a specific version in your `Podfile`, run `pod update ContextSDK`.
{% endtab %}

{% tab title="Manually" %}

1. Download the latest release: <https://storage.googleapis.com/de73e410-context-sdk-releases/latest/ContextSDK.zip>
2. Drag & Drop the `ContextSDK.xcframework` folder into the Xcode file list
3. Go to your project settings, scroll down to `Frameworks, Libraries, and Embedded Content`, add `ContextSDK.xcframework`, and select `Embed & Sign`

If you want to download a specific version, you can replace `latest` with the desired version number, e.g. [https://storage.googleapis.com/de73e410-context-sdk-releases/5.0.0/ContextSDK.zip](https://storage.googleapis.com/de73e410-context-sdk-releases/4.1.1/ContextSDK.zip)
{% endtab %}
{% endtabs %}
{% endtab %}

{% tab title="Android" %}
{% tabs %}
{% tab title="Kotlin" %}
Update the version specified in your module-level `build.gradle.kts`:

{% code title="build.gradle.kts" %}

```kotlin
dependencies {
    // …
    implementation("com.contextsdk:contextsdk:1.0.0")
    // …
}
```

{% endcode %}

And run a Gradle sync to install the latest version of ContextSDK.
{% endtab %}

{% tab title="Java" %}
Update the version specified in your module-level `build.gradle`:

{% code title="build.gradle" %}

```java
dependencies {
    // …
    implementation 'com.contextsdk:contextsdk:1.0.0'
    // …
}
```

{% endcode %}

And run a Gradle sync to install the latest version of ContextSDK.
{% endtab %}
{% endtabs %}
{% endtab %}

{% tab title="Flutter" %}

1. Update the `context_sdk` version in your `pubspec.yaml` to the latest version.
2. Run `flutter pub get`
3. Run `pod update` in the `ios/` directory
   {% endtab %}

{% tab title="Unity" %}

1. Download the latest version of ContextSDK: [ContextSDK.zip](https://storage.googleapis.com/de73e410-context-sdk-releases/latest/ContextSDK.zip).
2. In your Unity project, drag `ContextSDK.xcframework` into the `Assets/Plugins/iOS` folder.
3. Add the `ContextSDKBinding.cs` script in the same folder.

To download a specific version, replace `latest` in the download URL with the desired version number. For example, to download version 5.17.0:

<https://storage.googleapis.com/de73e410-context-sdk-releases/5.17.0/ContextSDK.zip>
{% endtab %}

{% tab title="React Native" %}
{% tabs %}
{% tab title="npm" %}

```sh
npm install react-native-context-sdk@latest
```

{% endtab %}

{% tab title="yarn" %}

```sh
yarn up react-native-context-sdk
```

{% endtab %}
{% endtabs %}
{% endtab %}
{% endtabs %}

### Validate the installation

After updating the SDK, please take a minute to go through the [release checklist](/context-decision/release-checklist) again, to ensure the SDK is correctly installed and configured, using the `validate` method. Just following the first step of the release checklist will prevent almost all potential integration errors.


# Minimum SDK Requirements

Discover the minimum SDK requirements for all supported platforms.

| Platform     | Minimum Deployment Target |
| ------------ | ------------------------- |
| iOS          | iOS 16+                   |
| Android      | SDK 26 (Android 8.0+)     |
| React Native | `react-native: 0.75.2`    |
| Flutter      | `flutter: '>=3.3.0'`      |

{% hint style="info" %}
For React Native and Flutter, lower versions may work as expected, but compatibility with older versions hasn’t been tested.
{% endhint %}

{% hint style="warning" %}
The iOS minimum deployment target was raised from iOS 14 to **iOS 16** in ContextSDK 5.17.0. Earlier SDK versions still support iOS 14. If your app must keep an iOS 14 deployment target, stay on 5.16.0 and [get in touch](mailto:support@contextsdk.com) — we can advise on a migration path.
{% endhint %}


# FAQ

Get answers to common questions about ContextSDK.

## What devices does ContextSDK support?

ContextSDK works on iOS and iPadOS devices as well as Android smartphones. It does not support devices that run macOS, watchOS, tvOS, or visionOS.

## Does ContextSDK require any additional iOS permissions?

No, ContextSDK was built to work without any iOS permissions: all data that is used to determine the users real world context is accessible to any app, and only uses official Apple APIs (no private APIs). No ATT is needed.

## Does ContextSDK use any type of PII?

No, ContextSDK was designed from the ground up to operate without the use of any PII (Personally Identifiable Information). The SDK does not process or store any PII. Also, as per our agreement, you must not send any PII to ContextSDK, as part of any of the parameters or events you send via the SDK.

## Does the SDK support Objective-C?

Currently, due to limited demand, we don’t support Objective-C. However, we can add it easily if you’re interested — [let us know](/other/help)!

## How does the ML model training work?

* There is no on-device ML model training. No CPU or battery will be used for ML model training on your user's phones.
* All ML model training happens on our servers, and only after your app has logged enough data.
* Once the training of your custom ML model is ready, we can deploy your ML model over-the-air (OTA), or alternatively provide you with your own ContextSDK binary. See [Model Distribution Methods](/context-decision/advanced/model-distribution-methods) for more details.

## How do OTA model deployments work?

When ContextSDK collects the context data, the response of those network requests include the info about the latest available model. Unless you don't want to enable over-the-air updates (OTA) for your custom model, you don't need to do anything, and we will notify you once a new model is about be released.

Long-term, deploying new models will be self-serve on our [dashboard](https://dashboard.contextsdk.com), which will allow you to rollout new models, monitor their performance, and more. Currently, we're coordinating this process manually with you.

## Is there support for Swift Async Await?

We don’t support async await at the moment, but you can use ContextSDK as an async function by [wrapping it in a continuation](https://www.hackingwithswift.com/quick-start/concurrency/how-to-use-continuations-to-convert-completion-handlers-into-async-functions).

{% hint style="warning" %}
**Note:** You should only do this with `fetchContext` or `calibrate`. Doing this with `optimize` could have unintended consequences.
{% endhint %}

## How does ContextSDK address data drift?

Data drift occurs when changes in an app’s environment — such as pricing adjustments, seasonal trends, or gameplay dynamics — affect revenue and model performance over time. To mitigate this, ContextSDK offers adaptive model training tailored to your needs.

Instead of retraining models every 2–4 weeks, you can opt for more frequent updates — such as weekly or every other day — to ensure the models stay aligned with real-world changes. Additionally, our system can prioritize recent data, giving greater weight to the most relevant insights.

While our data science team currently applies careful manual oversight to these updates, fully automated hourly or daily retraining isn’t yet available. However, you can send custom signals to refine data processing, allowing us to filter specific inputs based on your business needs. If your app undergoes significant changes — such as a redesigned experience or pricing adjustments — let us know. This helps ensure that models properly account for shifts in user behavior.

These updates roll out over the air to all apps automatically. Because our models are lightweight — typically around 500 KB — they have no impact on app performance or startup time.

## Is ContextSDK aware of its own past decisions?

Not yet. If your app evaluates context too frequently, the SDK may identify consecutive moments as “good moments” — especially if the user’s context hasn’t changed significantly. This is because the SDK currently has no built-in memory or cooldown mechanism. To avoid disrupting your app’s experience, we recommend implementing your own cooldown logic, such as: "show at most 1 paywall per app session" or "wait at least 1 hour between showing paywalls".

## How can I prevent test or development data from affecting the ML model?

No action is needed!

Even if you’re using the same bundle identifier across development, staging, and production, ContextSDK automatically distinguishes between real user data and internal or test data. This means events generated by your QA team, automation tests, or local development builds won’t influence the machine learning model.

Feel free to test as much as you need. Your production model remains unaffected.


# Get Help

Get support via email, video call, or Slack.

We offer multiple ways to get in touch, so you can reach us in the way that works best for you and your team.

### Email

If you prefer email, contact us at <support@contextsdk.com>.

### Video Call

Schedule a call with our engineering team via [Calendly](https://calendly.com/reinhard-contextsdk/call-with-reinhard).

### Slack

If you’re an existing customer and prefer Slack for direct communication, email us to set up a shared Slack Connect channel.


# Changelog

Track ContextSDK latest updates, new features, and fixes in our changelog.

### \[iOS] 5.17.0 - 2026-08-14

**Generic**

* **Breaking:** the minimum iOS deployment target is now **iOS 16** (previously iOS 14). This is required by the on-device Core ML models the SDK now ships with. Apps that must keep an iOS 14 deployment target should stay on 5.16.0 - see [Minimum SDK Requirements](/other/minimum-sdk-requirements)
* Add new `ContextManager.setUserId(_:)` function to set the user ID once for all ContextSDK products, or pass `nil` to clear it on sign-out. The value is stored on the device, so it's still there on the next app start. See [Tracking the User ID](/context-decision/advanced/tracking-the-user-id)
* Add new `ContextManager.fetchCurrentActivity` function that runs our on-device activity recognition models on demand and returns the probability that the user is walking, in transit, sitting and more, with a second overload to score only the activities you ask for. This API isn't part of the default build, [get in touch](/other/help) if you'd like it enabled for your app. See [Activity Recognition](/advanced/activity-recognition)
* Add new `sessionTimeoutSeconds` configuration option to change how long the app has to stay in the background before events are grouped into a new session. It defaults to 5 minutes, so brief background and foreground transitions (like pulling down the notification shade) keep the same session. See [Custom Configuration](/advanced/custom-configuration)
* Custom signals whose ID can't be used, or whose numeric value is `NaN` or infinite, are now reported in the console once per signal ID instead of being dropped silently

**Ad Tech**

* Add new callback based `ContextManager.fetchGoldenSignals` to allow waiting for sensor data to be available
* Add support for passing custom signals directly into the golden signals call in addition to the global signals
* Golden signals are now backed by OTA delivered CoreML models allowing them to be evolved over time
* Golden signals now expose the outputs of those models: scores under `m_` keys, moment embeddings as `[Float]` vectors with the model version under the matching `mv_` key. See [Golden Signals](/adtech/golden-signals)

**ContextPush:**

* Fix a crash in the host app when a push payload contained a `NaN` or infinite signal value
* `ContextPush.setUserId(_:)` is now deprecated in favor of `ContextManager.setUserId(_:)`. Existing calls keep working, and for push you only need to provide the device token via `ContextPush.setDeviceToken(_:)`

**Extension Build**

* Add new `uploadsEnabled` parameter to `ContextManager.setupForExtension` to stop an extension from uploading events itself. Events are still recorded and are uploaded the next time the app launches
* Reduce the memory footprint of the notification service extension by optimizing how data is shared to the main app

### \[iOS] 5.16.0 - 2026-05-05

**Generic**

* Add new `ContextManager.didReceiveMemoryWarning()` function to release memory that is no longer strictly required

**ContextPush:**

* Add support for notification service extensions to track generic events in a lifecycle safe way

### \[iOS] 5.15.0 - 2026-03-31

**ContextPush:**

* Add influenced open tracking to measure when users open the app after receiving a push notification
* Add richer context signals to push payloads for improved delivery optimization
* Add Swift Package Manager support for the notification service extension as a separate target

### \[iOS] 5.14.1 - 2026-02-12

* Fixes the `ABTestInformation` not having an initializer

### \[iOS] 5.14.0 - 2026-01-29

**ContextPush:**

* Add notification service extension support for confirmed delivery tracking
* Add API to customize notification content before delivery
* Bug fixes and performance improvements

**ContextDecision & ContextPush:**

* Introduce adaptive thresholds that automatically adjust based on your app's outcomes and user behavior for improved performance over time

### \[iOS] 5.13.0 - 2025-11-06

**ContextPush**:

* This includes the new Hybrid Mode, increasing the number of opportunities we get to deliver push notifications

**AdTech**:

* New AdTech capabilities

### \[iOS] 5.12.0 - 2025-10-30

* New AdTech capabilities

### \[iOS] 5.11.0 - 2025-10-02

* Adds new `Context.wasForcedUpsell` flag to indicate if the current upsell was triggered in a bad moment.

### \[iOS] 5.10.1 - 2025-09-03

* Add 34 new motion signals that require only 0.01s of data
* Add new APIs to better support customers' AB testing systems

### \[iOS] 5.9.0 - 2025-08-07

* Add support for revenue tracking for StoreKit 1
* Improve ContextPush background time handling
* Update Unity binding

### \[iOS] 5.8.0 - 2025-07-24

* Added 43 new motion signals for more precise context detection

### \[iOS] 5.7.0 - 2025-07-18

* Introducing Cooldowns, allowing you to control a minimum and maximum number of prompts shown in a given time period
* Improvements on handling disabled push permissions

### \[iOS] 5.6.1 - 2025-06-16

* Add support for logging revenue data from Unity
* Add support for manually putting ContextSDK into control mode in Unity

### \[iOS] 5.6.0 - 2025-05-30

* Improve ContextPush performance
* Verify maximum length of `flowName` (500 chars)
* Add additional internet geolocation signals

### \[iOS] 5.5.0 - 2025-04-29

* Add new [ContextManager.registerContextObserver](https://context-sdk.github.io/docs-ios/documentation/contextsdk/contextmanager/registercontextobserver\(flowname:callbackonfirstgoodmoment:\)) method to get a callback when it's a good moment to show a prompt
  * Learn more in [Listening for Good Moments](https://docs.contextsdk.com/context-decision/advanced/listening-for-good-moments)
* Remove an Xcode console warning when using the SDK in a background thread
* Improve frequency of event flushing during development so it's easier to test

### \[iOS] 5.4.0 - 2025-03-13

* Add [enableInstantContext](https://context-sdk.github.io/docs-ios/documentation/contextsdk/configuration/enableinstantcontext) configuration for improved battery savings, especially when using only ContextPush
* Improve error output in the Xcode console

### \[iOS] 5.3.1 - 2025-02-21

* Expose properties on the [ContextModelInformation](https://context-sdk.github.io/docs-ios/documentation/contextsdk/contextmodelinformation) struct

### \[iOS] 5.3.0 - 2025-02-06

* Add support for sending images in context-aware push notifications
* Add new context signals
* Add [ContextManager.getCustomModel(for:)](https://context-sdk.github.io/docs-ios/documentation/contextsdk/contextmanager/getcustommodel\(for:\)) to check if a custom model was rolled out for a specific flow

### \[iOS] 5.2.1 - 2025-01-28

* Add initializer for [RevenueEvent](https://context-sdk.github.io/docs-ios/documentation/contextsdk/revenueevent) to support [logRevenueOutcome(from:outcome:)](https://context-sdk.github.io/docs-ios/documentation/contextsdk/context/logrevenueoutcome\(from:outcome:\)-49ixv) with a `RevenueEvent` object

### \[iOS] 5.2.0 - 2025-01-22

* Add [context.logRevenueOutcomeIfNotLoggedYet](https://context-sdk.github.io/docs-ios/documentation/contextsdk/context/logrevenueoutcomeifnotloggedyet\(from:outcome:\)-257dv) to track in-app purchase and subscription revenue

### \[iOS] 5.1.0 - 2025-01-22

* Add [context.logRevenueOutcome(from:outcome:)](https://context-sdk.github.io/docs-ios/documentation/contextsdk/context/logrevenueoutcome\(from:outcome:\)-43krq) to track in-app purchase and subscription revenue
* Improve efficiency of push-related network requests
* Improve internal detection of app launch reason

### \[iOS] 5.0.0 - 2025-01-21

* Launch ContextPush product — see [How ContextPush Works](https://docs.contextsdk.com/basics/how-context-push-works) for details
* Remove deprecated methods:
  * `ContextManager.checkIfGoodMomentForPushNotification` → use ContextPush instead
  * `ContextManager.deliverNotificationIfGoodMoment` → use ContextPush instead
  * `ContextManager.requestATTrackingAuthorizationForCalibration` → discontinued
  * `ContextManager.requestATTrackingAuthorizationIfGoodMoment` → discontinued
  * `ContextManager.requestATTTrackingAuthorization` → discontinued
  * `context.logAttAuthStatus` → discontinued
  * `defaultLogSender` property → discontinued

### \[iOS] 4.8.0 - 2024-12-03

* Add support for logging multiple outcomes on a single `Context` object

### \[iOS] 4.7.1 - 2024-11-13

* Fix crash affecting users on iOS 15
* Improve documentation

### \[iOS] 4.7.0 - 2024-10-15

* Enable custom models to make decisions based on custom signals
* Add new context signals
* Prevent "Duplicate signal IDs found" from being logged when using `ContextManager.recentContext`

### \[Android] 1.0.1 - 2024-10-01

* Fix issue with accelerometer data collection on certain devices

### \[iOS] 4.6.0 - 2024-09-30

* Add new signals
* Add Flutter support

### \[iOS] 4.5.1 - 2024-09-12

* Prepare for future releases

### \[iOS] 4.5.0 - 2024-08-14

* Add new accelerometer signals
* Add signals related to session duration

### \[iOS] 4.4.3 - 2024-08-01

* Bugfix release

### \[iOS] 4.4.2 - 2024-08-01

* Bugfix release

### \[iOS] 4.4.1 - 2024-07-31

* Add additional context signals

### \[iOS] 4.4.0 - 2024-07-29

* Add `cancelContextCallback` method
* Fix crash when creating contexts from multiple threads
* Preserve `recentContext` when app enters background
* Improve documentation

### \[iOS] 4.3.1 - 2024-07-08

* Remove async/await from the codebase to simplify backporting on iOS 13/14
* Fix crash caused by incorrect async backport linking

### \[Android] 1.0.0 - 2024-07-01

* Initial release of ContextSDK for Android

### \[iOS] 4.3.0 - 2024-06-03

* Add support for specifying a `maxDelay` when using `ContextManager.optimize(...)`

### \[iOS] 4.2.2 - 2024-05-21

* Fix issue where custom signals could be lost when using `ContextManager.optimize(...)`

### \[iOS] 4.2.0 - 2024-05-13

* Add support for React Native

### \[iOS] 4.1.2 - 2024-05-08

* Fix crash related to non-thread-safe cache access

### \[iOS] 4.1.1 - 2024-05-02

* Add `PrivacyInfo.xcprivacy` file for upcoming Apple requirements
* Add additional accelerometer signals
* Add basic Objective-C support
* Improve integration robustness

### \[iOS] 4.0.2 - 2024-04-12

* Add `ContextManager.recentContext(customModel: .customModelName)` to retrieve context for a specific custom model
* Add warnings for potential integration issues
* Improve accelerometer sensor handling
* Disable `CallKit` in calibration mode builds to avoid app rejections

### \[iOS] 4.0.0 - 2024-03-07

* Add support for OTA custom model updates (configurable)
* Remove `Context.customModelName` property
* Change `CustomModel` enum to `String`
* Add optional error reporting to backend to improve stability
* Convert `ContextManager.sdkVersion` from property to method
* Replace `context.appendCustomSignals()` with `context.appendOutcomeMetadata()`


# Custom Configuration

Learn how to tailor ContextSDK settings to suit your app's unique needs.

ContextSDK operates seamlessly in the background, designed from the ground up to minimize resource usage and have no impact on your app’s performance. For most use cases, we recommend keeping the default settings unless you have specific requirements.

If needed, you can customize these options by passing a `Configuration` object to the `applicationDidFinishLaunchingWithOptions` method, as seen here:

{% code title="AppDelegate.swift" %}

```swift
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    // …
    let config = Configuration(…)
    ContextManager.applicationDidFinishLaunchingWithOptions(launchOptions, licenseKey: "YOUR_LICENSE_KEY", configuration: config)
    // …
}
```

{% endcode %}

## Configuration Options

### App Group Identifier

{% hint style="info" %}
Required for ContextPush reporting because the Notification Service Extension relies on App Group data sharing.
{% endhint %}

The `appGroupIdentifier` enables data sharing between your main app and a Notification Service Extension. This allows the extension to verify your license and share user preferences.

```swift
let config = Configuration(appGroupIdentifier: "group.com.yourcompany.yourapp")
```

**Requirements:**

* Both main app and extension must have the App Groups capability enabled
* Both must use the same App Group identifier
* The main app must be launched at least once after configuration to initialize shared data

See [Notification Service Extension](/context-push/notification-service-extension) for complete setup instructions.

***

### Session Timeout

ContextSDK groups events into sessions using a simple, timeout-based model. A session lasts while your app is in the foreground and stays the same across brief background/foreground transitions (for example, when the user pulls down the notification shade). It only rolls over to a new session when the app returns to the foreground after being in the background for at least `sessionTimeoutSeconds` (or after the app is relaunched).

The default timeout is 5 minutes (`300` seconds). You can override it:

```swift
let config = Configuration(sessionTimeoutSeconds: 600)
```

Values of `0` or less are ignored, and the default is used instead.

***

For a comprehensive list of all configuration options, please refer to our [iOS SDK Reference (Configuration)](https://context-sdk.github.io/docs-ios/documentation/contextsdk/configuration).


# Capturing Context In Key Moments

Learn how your app’s screens and user actions correlate with real-world user contexts.

Our SDK provides event tracking APIs to capture user interactions within your app, enabling insights into usage across real-world contexts.

Logging these events is optional and for informational purposes only. It will not influence your app's behavior.

### Page Views

Track when users navigate between different screens. This data helps you understand which screens users interact with most frequently in various real-world contexts.

{% tabs %}
{% tab title="Swift" %}

```swift
ContextManager.trackPageView("page_identifier")
```

{% endtab %}

{% tab title="Android" %}

```kotlin
ContextSDK.trackPageView("page_identifier");
```

{% endtab %}

{% tab title="Flutter" %}

```dart
_contextSdkPlugin.trackPageView("page_identifier");
```

{% endtab %}

{% tab title="Unity" %}

```csharp
ContextSDKBinding.TrackPageView("page_identifier");
```

{% endtab %}

{% tab title="React Native" %}

```javascript
import { trackPageView } from 'react-native-context-sdk';

trackPageView("page_identifier");
```

{% endtab %}
{% endtabs %}

### User Actions

Record specific user actions, such as enabling a feature, tapping buttons, creating accounts, or sharing content. Capturing these events helps you gain insights into how user interactions correlate with different real-world scenarios.

{% tabs %}
{% tab title="Swift" %}

```swift
ContextManager.trackUserAction("user_tapped_share_button")
```

{% endtab %}

{% tab title="Android" %}

```kotlin
ContextSDK.trackUserAction("user_tapped_share_button");
```

{% endtab %}

{% tab title="Flutter" %}

```dart
_contextSdkPlugin.trackUserAction("user_tapped_share_button");
```

{% endtab %}

{% tab title="Unity" %}

```csharp
ContextSDKBinding.TrackUserAction("user_tapped_share_button");
```

{% endtab %}

{% tab title="React Native" %}

```javascript
import { trackUserAction } from 'react-native-context-sdk';

trackUserAction("user_tapped_share_button");
```

{% endtab %}
{% endtabs %}

### Generic Events

Track custom events that don't fall under the other categories. Alternatively, add this to your existing analytics code to easily log all your existing events into ContextSDK, allowing us to provide you insights without much effort.

{% tabs %}
{% tab title="Swift" %}

```swift
ContextManager.trackEvent("custom_event")
```

{% endtab %}

{% tab title="Android" %}

```kotlin
ContextSDK.trackEvent("custom_event");
```

{% endtab %}

{% tab title="Flutter" %}

```dart
_contextSdkPlugin.trackEvent("custom_event");
```

{% endtab %}

{% tab title="Unity" %}

```csharp
ContextSDKBinding.TrackEvent("custom_event");
```

{% endtab %}

{% tab title="React Native" %}

```javascript
import { trackEvent } from 'react-native-context-sdk';

trackEvent("custom_event");
```

{% endtab %}
{% endtabs %}


# Activity Recognition

Run ContextSDK's on-device activity recognition models on demand and read back the probability that the user is walking, in transit, sitting, and more.

ContextSDK can predict the user's current physical activity (walking, sitting, in transit, etc.) from the device's motion sensors. The result is a set of probabilities you can read directly, with no logged conversion needed.

## Getting access

The activity recognition API is **not included in the default ContextSDK build**. If you'd like to use it, [get in touch with us](/other/help) so we can enable it for your app and ship you a build that includes the `fetchCurrentActivity` API. Without it the symbols below won't be visible from your app.

## Fetching the full result

`ContextManager.fetchCurrentActivity` collects a short window of motion sensor data, runs the bundled activity models, and returns the probability for every activity ContextSDK can detect. The callback fires once on the main thread, typically a few seconds after the call.

Pass a `flowName` that identifies the call site (e.g. `"home_screen_tap"`, `"notification_received"`). Use a distinct value for each place in your app where you invoke this API.

{% tabs %}
{% tab title="Swift" %}

```swift
ContextManager.fetchCurrentActivity(flowName: "home_screen_tap") { status, result in
    guard status == .ok else {
        // The call didn't run; see the status reference below.
        return
    }

    if let walking = result.walking {
        print("walking: \(walking.probability ?? 0)")
        print("  running: \(walking.running ?? 0)")
        print("  stairs:  \(walking.stairs ?? 0)")
    }

    if let transit = result.transit {
        print("transit: \(transit.probability ?? 0)")
        print("  train:  \(transit.train ?? 0)")
        print("  tram:   \(transit.tram ?? 0)")
        print("  subway: \(transit.subway ?? 0)")
    }

    print("bike: \(result.bike ?? 0)")
    print("car:  \(result.car ?? 0)")
}
```

{% endtab %}
{% endtabs %}

Group activities such as `walking`, `transit`, `sittingOrStanding`, `table`, and `bed` are exposed as nested structs that contain both the group probability and its sub-activities. Leaf activities (`bike`, `car`, `airplane`, `pocket`) are exposed as `Float?` directly on the result. A `nil` field means the model didn't produce a score for that activity in this call.

## Scoring a subset of activities

If you only care about a few activities, pass them explicitly. The SDK will only load and run the matching models, which keeps CPU and memory usage lower than the full call.

{% tabs %}
{% tab title="Swift" %}

```swift
ContextManager.fetchCurrentActivity(flowName: "home_screen_tap", activities: [.walking, .transit]) { status, scores in
    guard status == .ok else { return }

    let walkingScore = scores[.walking] ?? 0
    let transitScore = scores[.transit] ?? 0
    // ...
}
```

{% endtab %}
{% endtabs %}

The `scores` dictionary is keyed by the `Activity` values you requested. Activities that didn't produce a score are omitted from the dictionary; an empty dictionary means the call didn't run successfully, so check `status`.

## Status reference

Both overloads pass an `ActivityRecognitionStatus` alongside the result so you can tell a successful prediction with low scores apart from a call that didn't run.

| Status               | Meaning                                                                                                                                                                         |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `.ok`                | The models ran. Individual probabilities may still be `nil` if a particular activity couldn't be scored, but the result is meaningful.                                          |
| `.timeout`           | The SDK couldn't collect enough motion data in time. Retry later (e.g. when the user is more likely to be holding the device steady).                                           |
| `.sensorUnavailable` | The device has no usable accelerometer or gyroscope (e.g. an iOS app running on a Mac without device hosting). Subsequent calls on the same device will return the same status. |

## Threading and timing

* The callback is always invoked on the main thread.
* The call is asynchronous; expect the callback a few seconds after invocation while the SDK collects sensor data.
* You can call this API from a background context (e.g. a notification service extension). The accelerometer is started and stopped automatically for the duration of the call.


# Handling Memory Warnings

Forward iOS memory warnings to ContextSDK so it can release non-critical caches.

iOS sends a memory warning to your app when the system is running low on memory and may terminate apps that don't reclaim enough quickly. ContextSDK exposes a method you can call from your existing memory-warning handler so it can release any optional in-memory data it doesn't need to keep around.

Calling this method has no effect on the SDK's correctness - anything released can be re-derived on demand, and recent contexts created via your own flow names are still available afterwards.

## Forwarding the memory warning

Call `ContextManager.didReceiveMemoryWarning()` from your `UIApplicationDelegate.applicationDidReceiveMemoryWarning(_:)`:

{% tabs %}
{% tab title="Swift" %}

```swift
func applicationDidReceiveMemoryWarning(_ application: UIApplication) {
    ContextManager.didReceiveMemoryWarning()
}
```

{% endtab %}
{% endtabs %}

The method is safe to call at any time after `applicationDidFinishLaunchingWithOptions(_:licenseKey:)`, and from any thread.


# Running ContextSDK On-Premises

Learn about deploying ContextSDK on-premises to maintain full control over your data processing.

We understand that some businesses operate in highly regulated environments and require full control over data processing locations.

If you’re interested in an on-premises solution for ContextSDK, contact us at <support@contextsdk.com>.


