---
id: "tools/targeting/checkpoints/implementation"
title: "Implementing Checkpoints"
description: "Call checkpoint() at each moment where RevenueCat might show something. Your Targeting rules decide what happens there. Call it directly where the moment happens, not from a wrapper."
permalink: "/docs/tools/targeting/checkpoints/implementation"
slug: "implementation"
version: "current"
original_source: "docs/tools/targeting/checkpoints/implementation.mdx"
---

> **AI agents:** This is the Markdown version of a RevenueCat documentation page. For the complete documentation index, see [llms.txt](https://www.revenuecat.com/docs/llms.txt).

Call `checkpoint()` at each moment where RevenueCat might show something. Your Targeting rules decide what happens there. Call it directly where the moment happens, not from a wrapper.

## Before you start

Ensure that:

- Your project is enrolled in the Targeting by Checkpoint beta.
- You use `RevenueCatUI` with the RevenueCat iOS SDK 5.96.0+ or Android SDK 10.28.1+.
- RevenueCat completes your purchases. [Observer Mode](https://www.revenuecat.com/docs/migrating-to-revenuecat/sdk-or-not/finishing-transactions) (`purchasesAreCompletedBy` set to your app) isn't supported yet.
- You opt in to the beta API: `@_spi(InviteOnlyCheckpointsApi)` on iOS, `@OptIn(InviteOnlyCheckpointsAPI::class)` on Android.

## Register a Checkpoint

A Checkpoint is a named moment in your app that your customer has reached, such as: the end of onboarding, a button to access a gated feature, an informational screen, a usage limit, etc.

Checkpoint identifiers aren't case-sensitive. They must start with an ASCII letter, use only ASCII letters, numbers, `_`, and `-`, and be no more than 255 characters long.

**Swift**

```swift
@_spi(InviteOnlyCheckpointsApi) import RevenueCat
@_spi(InviteOnlyCheckpointsApi) import RevenueCatUI

Purchases.shared.checkpoint("onboarding_completed") { result in
    // Runs when the customer moves past the Checkpoint.
    // `result.flow` is nil if the Checkpoint isn't configured or no rule matched.
    showHomeScreen()
}
```

**Kotlin**

```kotlin
@file:OptIn(InviteOnlyCheckpointsAPI::class)

import com.revenuecat.purchases.Purchases
import com.revenuecat.purchases.ui.revenuecatui.InviteOnlyCheckpointsAPI
import com.revenuecat.purchases.ui.revenuecatui.checkpoints.checkpoint

fun onOnboardingCompleted() {
    Purchases.sharedInstance.checkpoint("onboarding_completed") { result ->
        // Runs when the customer moves past the Checkpoint.
        // `result.flow` is null if the Checkpoint isn't configured or no rule matched.
        showHomeScreen()
    }
}
```

The first time a Checkpoint is reached in your app, it will be added to your list of available Checkpoints in the dashboard.

:::tip[Register Checkpoints ahead of time]

Add Checkpoints anywhere you **might** want to serve a Flow or Paywall later. A Checkpoint with no rules shows nothing: Customers pass straight through, and the Checkpoint continuation runs right away. You can turn it on from the dashboard at any time, without an app update.

:::

## Handle the result

The Checkpoint continuation (the closure or lambda you pass to `checkpoint()`) runs once at most, when the customer moves past the Checkpoint. When it runs, it receives a `CheckpointResult`. Its optional `flow` describes the completed Flow, if there was one.

| Outcome                                                    | Meaning                                                                                                     |
| ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `result.flow` is `nil` / `null`                            | Nothing was shown because the Checkpoint isn't configured or no rule matched.                               |
| `result.flow` contains a `FlowResult` with Entitlements    | The customer moved past the Checkpoint after gaining one or more Entitlements.                              |
| `result.flow` contains a `FlowResult` with no Entitlements | The customer moved past the Checkpoint, but no new Entitlements were reported.                              |
| The Checkpoint continuation doesn't run                    | The customer didn't move past the Checkpoint because they navigated back, or an error prevented completion. |

Put the code that moves the customer forward inside the Checkpoint continuation. If the customer navigates back, they stay where they were.

Errors the SDK encounters while serving a Checkpoint use its default error presenter, or you can [provide your own](#use-your-own-error-ui). Checkpoints fail closed: If an error prevents the Flow from completing, the Checkpoint continuation doesn't run and the customer stays where they were.

Offerings shown through your own [paywall presenter](https://www.revenuecat.com/docs/tools/targeting/checkpoints/implementation#use-your-own-paywall-ui) return the same results. When your presenter completes with `continued` or `closed`, the SDK refreshes `CustomerInfo` to find the Entitlements obtained. The Checkpoint continuation doesn't run after `navigatedBack`. Your custom paywall remains responsible for errors inside its own UI.

## Pass custom variables

:::info[Coming soon]

You can pass custom variables to `checkpoint()` today, but you can't yet create targeting rules that use them in the dashboard. Support is coming soon.

:::

Custom variables give the Checkpoint context. They're the same custom variables your Paywalls use, passed at the Checkpoint instead of when presenting the Paywall. The Paywall can display them as text. Once rules can target them (coming soon), use them to move app-side conditions, like pages remaining, into the rule instead of deciding in code whether to call `checkpoint()`.

**Swift**

```swift
@_spi(InviteOnlyCheckpointsApi) import RevenueCat
@_spi(InviteOnlyCheckpointsApi) import RevenueCatUI

Purchases.shared.checkpoint(
    "onboarding_completed",
    customVariables: ["goal": .string("lose_weight")]
) { result in
    let unlockedPro = result.flow?.obtainedEntitlements
        .contains { $0.entitlementInfo.identifier == "pro" } ?? false
    showHomeScreen(pro: unlockedPro)
}
```

**Kotlin**

```kotlin
@file:OptIn(InviteOnlyCheckpointsAPI::class)

import com.revenuecat.purchases.Purchases
import com.revenuecat.purchases.ui.revenuecatui.InviteOnlyCheckpointsAPI
import com.revenuecat.purchases.ui.revenuecatui.checkpoints.CheckpointParams
import com.revenuecat.purchases.ui.revenuecatui.checkpoints.checkpoint

fun onOnboardingCompleted() {
    val params = CheckpointParams {
        customVariables { "goal" to "lose_weight" }
    }

    Purchases.sharedInstance.checkpoint("onboarding_completed", params) { result ->
        val unlockedPro = result.flow?.obtainedEntitlements
            ?.any { it.entitlementInfo.identifier == "pro" } ?: false
        showHomeScreen(pro = unlockedPro)
    }
}
```

## Skip customers who already have access

Checkpoints don't skip entitled customers automatically, since they can also be used to serve upgrade flows, non-purchasing flows, etc.

If you want customers with an active entitlement to not be served a paywall, add an entitlement condition to the rule's audience to ensure they won't match the rule. If a customer does not match a rule at a Checkpoint, they'll proceed past the Checkpoint uninterrupted.

## Require an Entitlement to continue

Every Checkpoint is a soft gate today: Customers can close the Flow and the Checkpoint continuation still runs. Hard Paywalls are coming soon. Until then, check the Entitlement yourself in the continuation, and only continue if the customer has it.

**Swift**

```swift
@_spi(InviteOnlyCheckpointsApi) import RevenueCat
@_spi(InviteOnlyCheckpointsApi) import RevenueCatUI

Purchases.shared.checkpoint("access_locked_content") { _ in
    Purchases.shared.getCustomerInfo { customerInfo, _ in
        if customerInfo?.entitlements.active["pro"] != nil {
            continueToLockedContent()
        }
        // Otherwise, the customer stays where they are.
    }
}
```

**Kotlin**

```kotlin
@file:OptIn(InviteOnlyCheckpointsAPI::class)

import com.revenuecat.purchases.Purchases
import com.revenuecat.purchases.getCustomerInfoWith
import com.revenuecat.purchases.ui.revenuecatui.InviteOnlyCheckpointsAPI
import com.revenuecat.purchases.ui.revenuecatui.checkpoints.checkpoint

fun openLockedContent() {
    Purchases.sharedInstance.checkpoint("access_locked_content") { _ ->
        Purchases.sharedInstance.getCustomerInfoWith { customerInfo ->
            if (customerInfo.entitlements.active.containsKey("pro")) {
                continueToLockedContent()
            }
            // Otherwise, the customer stays where they are.
        }
    }
}
```

Pair this with a rule whose audience matches customers without the Entitlement, so they always see a Paywall here. `getCustomerInfo` returns cached data when it's available, so this check doesn't hold up the customer.

## Use your own paywall UI

To show your own paywall UI, pass a paywall presenter to the `checkpoint()` call. You can also register a global presenter as an app-wide default; it receives the Offering and Checkpoint identifier, so it can show a different Paywall per Checkpoint.

**Swift**

```swift
@_spi(InviteOnlyCheckpointsApi) import RevenueCat
@_spi(InviteOnlyCheckpointsApi) import RevenueCatUI

@MainActor
final class MyPaywallPresenter: PaywallPresenter {
    func present(
        params: PaywallPresentationParams,
        completion: @escaping PaywallPresentationCompletion
    ) {
        // Show your own paywall for `params.offering`.
        // Call completion exactly once when it closes.
        showCustomPaywall(offering: params.offering) {
            completion(.closed)
        }
    }
}

@MainActor
func configureRevenueCat() {
    Purchases.configure() // Your existing configuration code.

    // Used for every Checkpoint that serves an Offering.
    Purchases.shared.paywallPresenter = MyPaywallPresenter()
}

func onOnboardingCompleteClick() {
    // You can also override the presenter for a single Checkpoint call.
    Purchases.shared.checkpoint(
        "onboarding_completed",
        paywallPresenter: { params, completion in
            showCustomPaywall(offering: params.offering) {
                completion(.closed)
            }
        }
    ) { _ in
        showHomeScreen()
    }
}
```

**Kotlin**

```kotlin
@file:OptIn(InviteOnlyCheckpointsAPI::class)

import com.revenuecat.purchases.Purchases
import com.revenuecat.purchases.ui.revenuecatui.InviteOnlyCheckpointsAPI
import com.revenuecat.purchases.ui.revenuecatui.checkpoints.CheckpointParams
import com.revenuecat.purchases.ui.revenuecatui.checkpoints.PaywallPresenter
import com.revenuecat.purchases.ui.revenuecatui.checkpoints.checkpoint
import com.revenuecat.purchases.ui.revenuecatui.checkpoints.paywallPresenter

fun configureRevenueCat() {
    Purchases.configure() // Your existing configuration code.

    // Used for every Checkpoint that serves an Offering.
    Purchases.sharedInstance.paywallPresenter = PaywallPresenter { params, completion ->
        // Show your own paywall for `params.offering`.
        // Call completion exactly once when it closes.
        showCustomPaywall(params.offering) {
            completion.complete(PaywallPresenter.Completion.Result.Closed)
        }
    }
}

fun onOnboardingCompleteClick() {
    // You can also override the presenter for a single Checkpoint call.
    val params = CheckpointParams {
        paywallPresenter { params, completion ->
            showCustomPaywall(params.offering) {
                completion.complete(PaywallPresenter.Completion.Result.Closed)
            }
        }
    }

    Purchases.sharedInstance.checkpoint("onboarding_completed", params) {
        showHomeScreen()
    }
}
```

Complete with `continued`, `closed`, or `navigatedBack`. Call the completion exactly once; if you never call it, the Checkpoint never finishes.

## Use your own error UI

To show your own UI for errors the SDK encounters while serving a Checkpoint, pass an error presenter to `checkpoint()`. You can also register a global presenter as an app-wide default. It receives the error, Checkpoint identifier, custom variables, and failure kind. If you don't set an error presenter, the SDK shows its own error dialog. Your presenter owns the error UI; the SDK decides what happens after it finishes. Your custom paywall UI remains responsible for its own errors.

**Swift**

```swift
@_spi(InviteOnlyCheckpointsApi) import RevenueCat
@_spi(InviteOnlyCheckpointsApi) import RevenueCatUI

@MainActor
final class MyErrorPresenter: ErrorPresenter {
    func present(params: ErrorPresentationParams, completion: ErrorPresentationCompletion) {
        // Show your own error UI. The SDK decides what happens afterwards.
        showCustomError(params) {
            completion.complete()
        }
    }
}

@MainActor
func configureRevenueCat() {
    Purchases.configure() // Your existing configuration code.

    // Used for every Checkpoint error the SDK presents.
    Purchases.shared.errorPresenter = MyErrorPresenter()
}

func onOnboardingCompleteClick() {
    // You can also override the presenter for a single Checkpoint call.
    Purchases.shared.checkpoint(
        "onboarding_completed",
        errorPresenter: { params, completion in
            showCustomError(params) {
                completion.complete()
            }
        }
    ) { _ in
        showHomeScreen()
    }
}
```

**Kotlin**

```kotlin
@file:OptIn(InviteOnlyCheckpointsAPI::class)

import com.revenuecat.purchases.Purchases
import com.revenuecat.purchases.ui.revenuecatui.InviteOnlyCheckpointsAPI
import com.revenuecat.purchases.ui.revenuecatui.checkpoints.CheckpointParams
import com.revenuecat.purchases.ui.revenuecatui.checkpoints.ErrorPresenter
import com.revenuecat.purchases.ui.revenuecatui.checkpoints.checkpoint
import com.revenuecat.purchases.ui.revenuecatui.checkpoints.errorPresenter

fun configureRevenueCat() {
    Purchases.configure() // Your existing configuration code.

    // Used for every Checkpoint error the SDK presents.
    Purchases.sharedInstance.errorPresenter = ErrorPresenter { params, completion ->
        // Show your own error UI. The SDK decides what happens afterwards.
        showCustomError(params) {
            completion.complete()
        }
    }
}

fun onOnboardingCompleteClick() {
    // You can also override the presenter for a single Checkpoint call.
    val params = CheckpointParams {
        errorPresenter { params, completion ->
            showCustomError(params) {
                completion.complete()
            }
        }
    }

    Purchases.sharedInstance.checkpoint("onboarding_completed", params) {
        showHomeScreen()
    }
}
```

Call `complete()` exactly once after your error UI finishes. If you never call it, error handling never finishes.

## Migrate from Placements and Offerings

Migrating means replacing the code that picks and presents a Paywall with `checkpoint()` calls, then recreating your targeting as rules.

| Today                                                         | With Checkpoints                                                          |
| ------------------------------------------------------------- | ------------------------------------------------------------------------- |
| `getOfferings` / `getCurrentOfferingForPlacement`             | Deleted. The rule picks the Flow or Offering.                             |
| `presentPaywallIfNeeded` with `requiredEntitlementIdentifier` | `checkpoint()`, with the Entitlement condition in the rule's audience     |
| Branching on the paywall result                               | Code inside the Checkpoint continuation                                   |
| `NOT_PRESENTED` treated as an error                           | Normal: no rule matched, and the Checkpoint continuation runs immediately |
| Falling back to the current Offering                          | A catch-all rule                                                          |
| Embedded `PaywallView`                                        | Not supported yet. Checkpoints present Flows modally.                     |

### In your app

1. Replace each `presentPaywall`, `presentPaywallIfNeeded`, or Placement call with `checkpoint()`. Your Placement identifiers usually work as Checkpoint identifiers. Delete wrappers whose only job was presenting the Paywall.
2. Remove any `getOfferings` calls that served a paywall.
3. Move the code that ran after the Paywall into the [Checkpoint continuation](https://www.revenuecat.com/docs/tools/targeting/checkpoints/implementation#handle-the-result).
4. Remove Entitlement pre-checks. Put the Entitlement condition in the rule's audience instead.
5. For a custom paywall, pass a [paywall presenter](https://www.revenuecat.com/docs/tools/targeting/checkpoints/implementation#use-your-own-paywall-ui).
6. Optionally, use an [error presenter](https://www.revenuecat.com/docs/tools/targeting/checkpoints/implementation#use-your-own-error-ui) to handle errors from RevenueCat-presented Flows.

### In the dashboard

1. Create a Checkpoint for each moment.
2. Recreate your targeting conditions as audiences.
3. Create a rule per Checkpoint that serves the Paywall or Offering you used before.
4. If you relied on falling back to the current Offering, add a catch-all rule last. Without it, customers who match nothing see nothing.

Legacy Targeting rules aren't copied over automatically yet.

### Release

Older app versions keep using Offerings and legacy Targeting. Keep those rules live until most customers have updated.

## Reference

### SDK API

**iOS** (`RevenueCatUI`, iOS 15+)

```swift
@_spi(InviteOnlyCheckpointsApi) import RevenueCat
@_spi(InviteOnlyCheckpointsApi) import RevenueCatUI

extension Purchases {
    func checkpoint(
        _ identifier: String,
        customVariables: [String: CustomVariableValue] = [:],
        presentationMode: FlowPresentationMode = .default,
        paywallPresenter: PaywallPresentationHandler? = nil,
        errorPresenter: ErrorPresentationHandler? = nil,
        _ onPassed: @escaping (CheckpointResult) -> Void
    )

    @MainActor var paywallPresenter: PaywallPresenter? { get set }
    @MainActor var errorPresenter: ErrorPresenter? { get set }
}
```

**Android** (`revenuecatui`)

```kotlin
@file:OptIn(InviteOnlyCheckpointsAPI::class)

import com.revenuecat.purchases.ui.revenuecatui.InviteOnlyCheckpointsAPI

fun Purchases.checkpoint(checkpointIdentifier: String, callback: CheckpointPassedCallback)
fun Purchases.checkpoint(checkpointIdentifier: String, params: CheckpointParams?, callback: CheckpointPassedCallback)

var Purchases.paywallPresenter: PaywallPresenter?
var Purchases.errorPresenter: ErrorPresenter?

// Per-call options
CheckpointParams {
    customVariables { "key" to "value" }
    presentationMode(FlowPresentationMode.DEFAULT)
    paywallPresenter { params, completion -> /* ... */ }
    errorPresenter { params, completion -> /* ... */ }
}
```

Minimum versions: iOS SDK 5.96.0, Android SDK 10.28.1.

### Result

When the continuation runs, it receives a `CheckpointResult`. Its `flow` is `nil` / `null` when nothing was presented. Otherwise, `flow` contains a `FlowResult` whose `obtainedEntitlements` are the Entitlements the customer obtained during the Flow. Each item wraps an `EntitlementInfo`.

| Situation                                       | Outcome                                                           |
| ----------------------------------------------- | ----------------------------------------------------------------- |
| Checkpoint isn't configured, or no rule matched | `CheckpointResult` with `flow` set to `nil` / `null`              |
| Moved past after gaining a new Entitlement      | `CheckpointResult` with a `FlowResult` containing Entitlements    |
| Moved past without gaining a new Entitlement    | `CheckpointResult` with a `FlowResult` containing no Entitlements |
| Error prevented the Flow from completing        | Doesn't run                                                       |
| Navigated back                                  | Doesn't run                                                       |

### Custom variables

- Types: String, number, boolean.
- Keys: Letters, digits, and `_`. Invalid keys are dropped.
- Coming soon: rules will see them as `custom.<key>`. Paywalls can show them as text.

### Presentation mode

`FlowPresentationMode` controls how the SDK presents a Flow over your app.

| Mode              | iOS                | Android             | Behavior                                                                    |
| ----------------- | ------------------ | ------------------- | --------------------------------------------------------------------------- |
| Default           | `.default`         | `DEFAULT`           | Lets the SDK choose the presentation mode. It currently uses a modal sheet. |
| Modal full screen | `.modalFullScreen` | `MODAL_FULL_SCREEN` | Covers the whole screen.                                                    |
| Modal sheet       | `.modalSheet`      | `MODAL_SHEET`       | Presents a modal sheet over your app's content.                             |

Custom paywall presenters receive the resolved mode and are responsible for applying it to their own UI.

### Paywall presenter completion values

When your custom paywall presentation ends, call its completion with one of these values:

| iOS              | Android         | Meaning                                                                                                        |
| ---------------- | --------------- | -------------------------------------------------------------------------------------------------------------- |
| `.continued`     | `Continued`     | Customer continued past your paywall. The SDK refreshes `CustomerInfo`, then the Checkpoint continuation runs. |
| `.closed`        | `Closed`        | Customer closed your paywall. The SDK refreshes `CustomerInfo`, then the Checkpoint continuation runs.         |
| `.navigatedBack` | `NavigatedBack` | Customer navigated back. The Checkpoint continuation doesn't run.                                              |

### Error presenter failure kinds

Call `complete()` after your error UI finishes. This only reports that your UI is done; the SDK still owns the Flow outcome:

| iOS                     | Android                   | Failure                                                                                        | After `complete()`                                                                                                                                                            |
| ----------------------- | ------------------------- | ---------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `.flowCouldNotBeShown`  | `FLOW_COULD_NOT_BE_SHOWN` | The Flow couldn't be presented or its initial screen couldn't load.                            | No Flow continues. The customer remains before the Checkpoint, and the Checkpoint continuation doesn't run.                                                                   |
| `.flowActionFailed`     | `FLOW_ACTION_FAILED`      | An action failed, such as a purchase, restore, or loading the next screen.                     | The SDK returns to the current Flow if it remains usable. If presentation already ended, the customer remains before the Checkpoint. The Checkpoint continuation doesn't run. |
| `.flowResultSyncFailed` | `FLOW_RESULT_SYNC_FAILED` | A `PaywallPresenter` completed, but the SDK couldn't synchronize the resulting `CustomerInfo`. | The Checkpoint continuation runs with a `CheckpointResult` containing a `FlowResult` with no reported Entitlements.                                                           |

## Troubleshooting

### The Flow completed but no Entitlements were reported

If a `PaywallPresenter` completes but the resulting `CustomerInfo` can't be synchronized, the SDK presents the error and then runs the Checkpoint continuation with a `CheckpointResult` containing a `FlowResult` with no reported Entitlements.

### The Checkpoint continuation never runs

- The customer navigated back. This is expected.
- An error prevented the Flow from completing. This is expected.
- Your paywall presenter never called its completion.
- Your error presenter never called its completion.

## Next steps

- [Creating rules](https://www.revenuecat.com/docs/tools/targeting/checkpoints/targeting-rules)
- [Running experiments](https://www.revenuecat.com/docs/tools/targeting/checkpoints/experiments)
