# Introduction

Amply helps mobile teams control and optimize in-app user flows for growth and monetization — without app releases.

## Two audiences, one set of docs

These docs serve two audiences. Both read the same pages, but each comes in through a different door.

* **Product, growth, and marketing managers** own campaigns and scenarios, work in the dashboard, and read the User Guide, the Concepts pages, and the recipes.
* **Engineers** integrate the SDK once and maintain it — they read the Developer Guide and Reference.

Every Developer Guide page is written so a non-technical lead can skim it and hand a short brief to their engineer.

## Pick your path

[**User Guide**](/user-guide/overview) — for product, growth, and marketing. Set up projects, build campaigns, target users, launch paywall variants, and measure results. Start here if you own the outcome but not the code.

[**Developer Guide**](/developer-guide/installation) — for engineers integrating Amply. SDK install, initialization, event tracking, deeplink handling, gating actions, and the REST contract. Start here when you need to wire the SDK into an app.

New to Amply? The [Quickstart](/getting-started/quickstart) is the shortest path from zero to a working integration.

## Getting oriented

* [What is Amply](/getting-started/what-is-amply) — the problem it solves, the model it uses, and where it stops
* [How Amply fits](/getting-started/how-amply-fits) — where it sits in your stack and how data flows
* [Changelog](/changelog) — recent releases across dashboard and SDK

## Platforms

Amply ships SDKs for iOS, Android, and React Native. All platforms share the same core concepts — events, properties, scenarios, deeplinks — so once you learn Amply on one, the others follow. See [Installation](/developer-guide/installation) for the full requirements matrix.


# What is Amply

A deeper look at the product: the problem Amply solves, the model it uses, and where it stops.

## The decision layer for in-app experience

Most mobile apps run on paid acquisition, multiple ad creatives, Custom Product Pages, subscription monetization, and continuous experimentation. The outside of the funnel is dynamic.

Inside the app, experience is usually static. One onboarding for everyone. One paywall screen for everyone. Logic scattered across code and feature flags. Every meaningful change needs a release.

Amply closes that gap. It is a decision layer between the context a user arrives with and the experience the app shows them. You configure the decisions remotely, the SDK executes them, and you can change them without shipping a new build.

## Scenarios, not screens

Amply is not a screen builder. You build scenarios.

A scenario describes:

* **When it starts** — which event, attribute, or entry condition triggers it
* **Who it applies to** — audience rules over session, device, user, and custom data
* **What runs** — the sequence of screens, deeplinks, or actions the app performs
* **When it ends** — completion, time limit, or explicit stop

Screens and deeplinks are execution details. The scenario is what you own in the dashboard.

## What you can do with Amply

These are the cases Amply is built for.

**Entry-based onboarding routing.** Different ads and Custom Product Pages lead users to different onboarding screens in your app. Organic users get the default. All routing is configured remotely via deeplink campaigns.

**In-app rating prompts at the right moment.** Fire the platform's native rate-review prompt at a specific event and only for users who meet your targeting rules — a successful purchase, a milestone, a positive in-app feedback answer.

**Post-trial recovery.** Detect trial end, fire a deeplink campaign that routes to your recovery or offer screen, cap the frequency so users don't see it repeatedly.

**Reactivation after inactivity.** Detect that a user hasn't opened the app in N days, route them to a reactivation screen you already have in the app.

**Seasonal and event-based campaigns.** Create time-bound campaigns for a holiday or promotion, target the audience, let them deactivate automatically.

## What Amply is not

Amply is deliberately narrow. It is not:

* A BI dashboard
* A push notification platform
* A full marketing automation suite
* A replacement for RevenueCat
* A cross-channel enterprise engagement system
* A CMS for in-app content
* An A/B testing platform

If you need those, keep the tools you use. Amply coordinates with them.

## Who Amply is for

Amply is built for teams that actively manage growth and monetization in mobile apps and want to move fast.

* **Solo founders** running one or several apps who cannot ship a release every time a paywall needs adjusting.
* **Small and mid-size mobile teams** who want remote control over onboarding and paywalls without standing up internal tooling.
* **Growth and product managers** who own conversion metrics and need a tool they can operate without a developer in the loop for every change.
* **Performance marketing teams** running paid traffic who need the in-app experience to match the ad that brought the user in.

If you are an enterprise with omnichannel messaging, cross-device orchestration, and a dedicated customer data platform, Amply is probably not the shape of tool you need. Amply solves a narrower, sharper problem.

## How it works, briefly

The SDK runs in your app and collects session, device, and event data. When an event fires or a screen loads, the SDK checks the campaigns you have published. If a campaign matches, the SDK executes its action — opening a deeplink to one of your app's screens, prompting a native rate review, or handing off to your own code.

You manage the scenarios in the Amply dashboard. You publish changes from the dashboard. The SDK picks them up on the next session.

For the full picture, see [How Amply fits](/getting-started/how-amply-fits).

## How Amply differs from adjacent tools

**Versus enterprise engagement platforms.** Enterprise platforms focus on omnichannel messaging, cross-device orchestration, and enterprise data infrastructure. Amply is narrower by design — in-app routing, onboarding orchestration, entry-based flows. If you have a dedicated customer data platform and a large operations team, Amply is not the right shape.

**Versus feature flag tools.** Feature flags toggle code paths. Amply orchestrates user-visible campaigns with targeting, triggers, and frequency rules built in. You can use both; they solve different problems.

**Versus A/B testing tools.** Amply is not an A/B testing tool — it controls *which scenario a user enters*, not which variant of a widget they see. Use your experimentation tool for the variant test; use Amply for the in-app decision layer around it.

**Versus your analytics tool.** Amply has its own event stream for targeting. It is not a BI or product analytics replacement — you should continue to send events to your analytics tool for reporting and exploration.

## A note on scope

Amply is built to be lightweight enough for a solo founder to operate and powerful enough to grow with a team. It stays narrow on purpose. When a feature would push Amply into push notifications, BI, or full customer lifecycle management, we say no. The value is in doing one thing — the in-app decision layer — well.

## Related

* [How Amply fits](/getting-started/how-amply-fits) — architecture view of where Amply sits in your stack
* [Quickstart](/getting-started/quickstart) — get a working integration in under an hour
* [User Guide](/user-guide/overview) — build your first campaign
* [Developer Guide](/developer-guide/installation) — integrate the SDK


# How Amply fits

Where Amply sits in your stack, what data it uses, and how a decision travels from a user action to a surfaced experience.

## The three sides Amply connects

Amply connects three sides of your app's growth loop.

**Acquisition surfaces.** How users arrive: deeplinks, Custom Product Pages, ad campaigns, organic installs, referrals. Each of these carries context Amply can act on — the campaign a user came from, the offer an ad promised, the product page they saw.

**App data.** What you know about the user: session data, device attributes, events they have triggered, custom properties you have set (plan tier, trial status, country, anything you track), and subscription state from the tool that owns it.

**In-app experiences.** What the app shows: onboarding flows, paywalls, popups, review prompts, deeplinks to specific screens. These are what Amply decides between.

Amply is the layer in the middle. It reads the context, evaluates the rules you have published, and tells the app which experience to run.

## Data flow

Here is what happens during a session.

```
  User opens app
        │
        ▼
  SDK starts a session, collects device + session data
        │
        ▼
  App calls track("EventName", properties)
        │
        ▼
  SDK evaluates published scenarios against current context
        │
        ▼
  Matching scenario? ── yes ──▶ SDK runs the scenario's steps
        │                       (deeplink, paywall, action, etc.)
        │ no
        ▼
  App continues as normal
```

More concretely, the flow is:

1. **Session starts.** The SDK initializes and collects baseline context — device, install date, user properties you have set.
2. **App emits events.** Your code calls `track()` for meaningful moments: onboarding step completed, purchase screen shown, trial started, anything you want to act on.
3. **Scenario evaluation.** The SDK checks the current event against the scenarios published for your app. Rules are evaluated locally using the configuration the SDK has already fetched.
4. **Scenario execution.** If a scenario matches, the SDK triggers its steps. That might be opening a deeplink you handle, showing a screen you have registered, prompting a store review, or firing a callback your app listens to.
5. **Your app responds.** The SDK hands control back to your code to render the actual UI. Amply decides what to show; your app shows it.

Scenarios themselves are configured in the dashboard. When you publish a change, the SDK picks it up on the next session — no app release needed.

## What stays where

Amply does not try to own every piece of user data. It coordinates with the tools you already run.

* **Subscription state** lives in RevenueCat (or whatever you use). Amply reads it through custom properties you set from your app code, or through events you emit when a subscription changes.
* **Analytics** stays in your analytics tool. Amply has its own event stream for targeting, but it is not a replacement for product analytics or BI.
* **Push notifications** stay with your push provider. Amply triggers in-app experiences, not off-app messaging.
* **Your business logic** stays in your app. Amply decides which experience to surface; your code owns the screens and the domain logic behind them.

## Where each audience touches Amply

Two audiences interact with Amply in different places.

**Product, growth, and marketing** work in the dashboard. They define projects and applications, build campaigns with targeting and triggers, configure scenarios, and watch results. They do not touch the SDK.

**Developers** integrate the SDK once. They initialize it with credentials from the dashboard, instrument key events with `track()`, set user properties when they become known (user id, plan, country), and register handlers for deeplinks and scenario actions. After integration, they rarely need to change anything for new campaigns — most campaigns reuse events and deeplinks that already exist.

## What the SDK keeps locally

The SDK keeps a small set of local data to make decisions fast and offline-tolerant.

* **Session state** — when the current session started and what has happened in it
* **Device context** — platform, version, locale, install date, anything collected at first launch
* **User identity and custom properties** — whatever you set via `setUserId` and `setCustomProperty`
* **Recent events** — a rolling buffer, available for debugging via `getRecentEvents`
* **Published scenario configuration** — what to match and what to run, refreshed between sessions

None of this is a long-term data warehouse. Analytics, history, and reporting stay in your dedicated tools. The SDK only keeps what it needs to make routing decisions.

## A concrete example

A user installs your app from a Custom Product Page tied to a fitness challenge ad.

1. Your attribution provider receives the CPP install and hands the app a campaign identifier. Your code takes that identifier and passes it to Amply as a custom property, e.g. `setCustomProperty("entry_campaign", "fitness_challenge")`. Amply itself does no attribution — it just reads whatever your app sets.
2. The user finishes the app's registration step. Your app calls `track("OnboardingStarted")`.
3. The SDK evaluates scenarios. A scenario targets `entry_campaign = "fitness_challenge"` on `OnboardingStarted` and triggers a deeplink to a fitness-themed onboarding flow.
4. The SDK fires the deeplink; your app handles it and shows the themed onboarding.
5. Later, after a trial ends, another scenario targeting `trial_state = "expired"` fires and shows a recovery paywall variant — one you launched yesterday without releasing the app.

Same app binary, different users, different experiences, all configured from the dashboard.

## Related

* [What is Amply](/getting-started/what-is-amply) — the product in plain language
* [Quickstart](/getting-started/quickstart) — set up a project and send your first event
* [Developer Guide](/developer-guide/installation) — SDK integration details
* [User Guide](/user-guide/overview) — build campaigns in the dashboard


# Quickstart

The fastest path from zero to a working Amply integration. Set up a project in the dashboard, then jump to your platform-specific tutorial.

**Prefer not to do it by hand?** Use [AI-assisted integration](/getting-started/ai-assisted-integration): connect the Amply MCP and the `amply-integration` skill to your AI coding assistant and have it wire the SDK and hand you a diff to review. You still create the project and application here in the admin — the assistant asks you for the keys. This page is the by-hand path.

**Prerequisites**

* An Amply dashboard account ([sign in](https://app.amply.tools))
* Your app project on iOS, Android, or React Native

**You'll end with**

* A project and application created in the Amply dashboard
* API keys ready to paste into your app
* A clear next step into the platform tutorial that wires the SDK and sends your first event

## 1. Create a project in the dashboard

A project groups one or more applications (iOS, Android, etc.) under the same set of campaigns and settings.

1. Open the dashboard.
2. Click **New project** and give it a name that matches your product.
3. Inside the project, click **New application**. Enter a name, set **Platform** to **iOS** or **Android**, and enter your app's **Bundle Id**.

Repeat the second step for each platform you ship.

## 2. Get your API keys

Open the application you just created and go to the **API Keys** tab. Click **Add Api Key** to generate a key pair. You'll need three values for the SDK:

* **App ID** — the application's identifier (visible on the application page).
* **Public key** — from the API Keys table.
* **Secret key** — from the API Keys table (the value is masked; use the copy button to grab it).

Hand all three to whoever wires the SDK into the app — typically your engineer.

## 3. Pick your platform tutorial

The next step is platform-specific. Each tutorial installs the SDK, initializes it with the keys from step 2, sends a first event, and verifies it shows up in the dashboard.

* [iOS Quickstart](/developer-guide/quickstart-ios) — Swift, UIKit, and SwiftUI.
* [Android Quickstart](/developer-guide/quickstart-android) — Kotlin and Gradle.
* [React Native Quickstart](/developer-guide/quickstart-react-native) — JS/TS, both Bare and Expo.

The concepts are the same across platforms; the API shapes differ.

## What's next

After your first event reaches the dashboard:

* [Create your first campaign](/user-guide/creating-a-campaign) — act on the event you just sent.
* [Set user identity and properties](/developer-guide/user-attributes) — target users more precisely.
* [Handle deeplinks from campaigns](/developer-guide/handling-deeplinks) — let scenarios navigate inside your app.

## Related

* [How Amply fits](/getting-started/how-amply-fits) — what the SDK does with the events you send
* [Installation](/developer-guide/installation) — versions, deployment targets, and package managers per platform
* [User Guide](/user-guide/overview) — build and manage campaigns


# AI-assisted integration

Integrate Amply and build campaigns by directing an AI coding assistant — it does the work, you review the diff.

There are two ways to get Amply into your app. You can wire it **by hand** — add the SDK, forward your events, set the attributes you target on (that's the [Developer Guide](/developer-guide/installation)). Or you can let an **AI coding assistant do it for you** — this page.

Connect Amply to your assistant and it does the work: it reads your app and sets the SDK up, and it turns a plain-language request into a real campaign. You review and approve; the assistant does the legwork.

This doesn't replace the dashboard or the SDK — it's a faster door to both. Developers can still integrate by hand and PMs can still build campaigns in the dashboard. The AI-assisted path is for teams who'd rather describe the outcome and review the result than do every step themselves.

## Set it up

Two pieces, set up once. You need an MCP-capable AI assistant (Claude Code, the Codex CLI, or any client that supports MCP). Connecting installs nothing; the skill in step 2 installs either from inside Claude Code, or with the `skills` CLI, which needs [Node](https://nodejs.org).

**1. The Amply MCP** — connects your assistant to your Amply account. Amply hosts it, so you point your assistant at a URL:

```bash
claude mcp add --transport http amply https://api.amply.tools/mcp
```

Then run `/mcp` in Claude Code and authenticate. A browser window opens, you sign in to Amply, and you approve what the assistant may do — reading your projects, creating draft campaigns, and so on. Amply keeps those permissions separate, so creating a draft can never be the thing that makes it live, but approving is one decision rather than a checklist. You never hand over your password, and you can revoke the connection at any time under **Profile settings → Connected apps**.

Any client that supports a remote MCP server over HTTP takes the same URL. See [MCP tools](/reference/mcp-tools) for the full permission list and every tool.

{% hint style="info" %}
If you used Amply's old local MCP — the `@amplytools/amply-mcp` npm package — it is retired and no longer works. Remove it, connect with the command above, and delete `~/.amply/credentials.json`.
{% endhint %}

**2. The integration skill (`amply-integration`)** — an open-source playbook that tells the assistant *how* to add Amply well: detect the platform and package manager, audit the analytics the app already has, wire the SDK through a single thin layer, set up the custom properties you'll target on, and verify the build itself.

Install it whichever way suits your assistant:

{% tabs %}
{% tab title="Any assistant (skills CLI)" %}
Works with Claude Code, the Codex CLI, and any other host the [`skills` CLI](https://skills.sh) supports:

```bash
npx skills add amply-tools/sdk-skill
```

Add `-g` to install for your user instead of the current project. Target a specific assistant with `-a` (e.g. `-a claude-code` or `-a codex`); run `npx skills --help` for the full list of supported assistants.
{% endtab %}

{% tab title="Claude Code (plugin)" %}
Claude Code can install the skill as a plugin from Amply's plugin catalog. Run both commands once, inside Claude Code:

```
/plugin marketplace add amply-tools/claude-plugins
/plugin install amply-integration@amply
```

The first command **adds Amply's plugin catalog** — it installs nothing on its own. The second command **installs the skill** from that catalog. (`amply` is the catalog name; `amply-integration` is the skill.)
{% endtab %}
{% endtabs %}

Either path installs the same skill — pick one. Source: [github.com/amply-tools/sdk-skill](https://github.com/amply-tools/sdk-skill)

The MCP is the *connection* to your account; the skill is the *know-how* for a clean integration. With both in place you describe the outcome and review the result.

## Integration, the AI way

Integrating an analytics-style SDK is usually a checklist a developer works through by hand: install it, find the events the app already fires, forward the ones that matter, and set the user attributes you'll target on. It's mechanical, easy to get subtly wrong, and slow to repeat across apps.

> When a developer connects the Amply MCP to their AI coding assistant and asks it to add Amply, the assistant audits the app's existing analytics, installs and initializes the SDK, forwards the events Amply needs for targeting, and sets up custom properties — as a reviewable change in the codebase, not a black box. The developer reads the diff and merges.

The result: the same integration a developer would do, produced in one pass and handed back for review — so the judgment stays with you and the typing doesn't.

If you granted it the permission to register apps, the assistant does the whole thing: it creates the project, registers the application, takes its `appId` and API key, and writes them into your app's configuration — you never copy a value out of the admin. Without that permission it asks you for those three values instead and carries on from there. Either way the code, the events and the properties are its work.

## Running campaigns by describing them

Campaign work normally means a person in the dashboard translating an idea into targeting, a trigger, and an action. MCP lets you state the idea and get the campaign.

> When a PM asks the assistant to "show a rewarded-ad gate before export, for free users, once per session," the MCP turns that into a campaign in Amply — audience, trigger, and action wired — for you to review and activate. Checking how an existing campaign is set up works the same way: ask, and it reads it back to you.

Common shapes are a single request away — a deeplink when a user's status changes (for example, trial → expired), a prompt after a positive moment, or a paywall on the Nth session. The assistant fills in your audience and trigger and leaves the campaign in Draft for you to review.

Two limits worth knowing before you start. The assistant **cannot edit a campaign** — if a draft comes out wrong it will offer you a new one, and you finish or fix it in the dashboard. And on create it can target by country, application, app version and OS version; audiences built on custom properties, install date or event history are authored in the dashboard.

You stay in control of what goes live: the assistant proposes, you approve in the dashboard. It compresses the path from idea to live campaign; it doesn't take the decision away from you.

## When to reach for it

* **Integrating, or adding Amply to another app** — let the assistant do the SDK pass and hand you a diff.
* **Spinning up a campaign quickly** — describe it instead of clicking through the form, then review and activate.
* **You'd still use the dashboard** for a careful look before activating, and for anything you'd rather see laid out visually.

## Related

* [Quickstart](/getting-started/quickstart) — the by-hand path from zero to a working integration
* [User Guide](/user-guide/overview) — building and managing campaigns in the dashboard
* [Developer Guide](/developer-guide/installation) — integrating the SDK by hand
* [amply-integration skill](https://github.com/amply-tools/sdk-skill) — the open-source playbook the assistant follows


# Scenarios and campaigns

How Amply separates the product thinking ("what flow should this user get?") from the operational unit you configure in the dashboard ("this campaign, live today, for these users").

## Scenario — the user flow you want to orchestrate

A scenario is the product-level outcome you are trying to produce. It describes what should happen to a user across time:

* A new install from a paid ad lands on an onboarding variant matched to the ad.
* A user finishes their trial and gets a recovery offer on the next cold open.
* A returning user who hasn't opened the app in 10 days gets a winback prompt.

Scenarios are how PMs and growth managers think about the work. They have a start, a middle, an end, and a reason. They usually involve more than one thing happening in sequence.

Amply does not store a "scenario" object. The scenario lives in your head, in the spec, and on the whiteboard. The dashboard stores the pieces that deliver it.

## Campaign — the primitive you configure in the dashboard

A campaign is the unit of delivery. One campaign produces one user-visible outcome (a deeplink opens, the native rate prompt appears) under one set of conditions. A scenario is built from one or more campaigns working together.

Every campaign has three parts:

* **Who** — targeting conditions that restrict which users are eligible (attributes like country, OS version, app version, install date, custom properties you set on the user).
* **When** — a triggering event plus frequency rules (how often, how many times).
* **What** — the action to perform when the campaign fires (open a deeplink, show the native rate prompt).

See [Targeting and audiences](/concepts/targeting-and-audiences) for the Who, [Campaign delivery](/concepts/campaign-delivery) for the When, and [Campaign actions](/concepts/campaign-actions) for the What.

## Campaign lifecycle

A campaign moves between three states:

| State  | Meaning                                                                                                        |
| ------ | -------------------------------------------------------------------------------------------------------------- |
| Draft  | The campaign exists in the dashboard but is not delivered to any device. Use this while you are still editing. |
| Active | The campaign is live. Eligible users will see it when its trigger fires.                                       |
| Cancel | The campaign is retired. It no longer evaluates on device.                                                     |

State changes propagate to the SDK on its next configuration fetch. Because campaigns are evaluated from a server-delivered configuration, flipping a campaign to Cancel stops it for every user on their next refresh — no app release needed. See [Campaign delivery](/concepts/campaign-delivery) for the delivery model.

## How campaigns compose into scenarios

A scenario is usually not one campaign. It's a handful of campaigns that share audience, track the same signals, and hand off to each other.

Example — post-trial recovery scenario:

* **Campaign A** detects trial end (triggered by a custom `TrialEnded` event you track) and deeplinks the user to a recovery offer screen on their next session start.
* **Campaign B** targets the same users 48 hours later, triggered by `SessionStarted`, and routes to a second, softer offer.
* **Campaign C** prompts for an app rating once the user has converted (triggered by `SubscriptionActivated`).

Each campaign is simple. The scenario is the pattern across them.

## When to add another campaign vs. widen an existing one

Split a campaign when the audience, trigger, or action changes. Keep it as one campaign when you only want to vary volume or cadence.

* Two audiences receiving the same deeplink → one campaign, broad targeting.
* Same audience receiving different deeplinks depending on a property → two campaigns.
* Same audience and deeplink, but one should fire on session start and the other on a custom event → two campaigns.

## Campaign types

Amply supports a small, fixed set of campaign types, each producing a specific user-visible action:

* **DeepLink campaign** — opens a URL on the device. Route the user to a screen inside your app, or to an external URL. See [Campaign actions](/concepts/campaign-actions).
* **RateReview campaign** — presents the platform's native rating prompt (App Store, Google Play).

> **On naming.** This documentation uses `DeepLink` and `RateReview` when talking about campaign types as an abstract concept. The dashboard UI labels the same types as **Deep Link** and **Rate & Review** on buttons and tabs. Both refer to the exact same thing.

## Related

* [Targeting and audiences](/concepts/targeting-and-audiences) — how the "Who" is built
* [Campaign delivery](/concepts/campaign-delivery) — how the "When" is built and how rollout works
* [Campaign actions](/concepts/campaign-actions) — the "What" actions
* [Creating a campaign](/user-guide/creating-a-campaign) — step-by-step how-to in the dashboard
* [Testing and rollout](/user-guide/testing-and-rollout) — how to take a campaign from Draft to Active safely


# Sessions and events

Amply reasons about users through two units: sessions (when the app was in use) and events (what happened during that use). Everything else — targeting rules, campaign triggers, frequency caps — is built on top of these two ideas.

## What a session is

A session is a continuous period of app usage. The SDK starts a session when the app comes to the foreground and ends it when the app goes to the background. A user with ten app-opens across a day has ten sessions.

Two kinds of session start:

* **Cold start** — the app was launched fresh (not running before).
* **Warm start** — the app was already running and returned to the foreground.

Short background trips are merged back into the same session rather than starting a new one, so a quick switch to another app and back does not inflate your session count.

On each session start the SDK emits the `SessionStarted` standard event. On session end it emits `SessionFinished`.

### Session counter

Amply keeps a running counter of how many sessions a user has had. The SDK uses it for delivery and frequency logic. The rule builder doesn't expose session count as a first-class targeting attribute — if you need to target on it, set it as a custom property from your app and reference that in the **Who** step. See [User attributes](/concepts/user-attributes) for how custom properties work, and [Campaign delivery](/concepts/campaign-delivery) for how the counter feeds frequency rules.

## What an event is

An event is a named thing that happened inside a session. It has:

* A **name** (a short string — `PurchaseCompleted`, `OnboardingStepViewed`).
* A **timestamp** (synchronized with Amply's server clock so events from different devices are comparable).
* Optional **properties** — a flat map of primitive values that describe the specifics of this occurrence (`plan: "annual"`, `price: 29.99`).

Events are what campaign triggers watch for. When an event arrives, the SDK checks every active campaign to see if that event matches the campaign's trigger.

## Standard events vs. custom events

Events fall into two buckets.

### Standard events (SDK-emitted)

The SDK emits these automatically — session start/finish, config fetch, campaign shown, custom-property changes, and more. You can target and trigger campaigns against them without writing any tracking code. For the complete list with each event's exact properties, see the [Events reference](/reference/events).

Standard events have the event type `system` in the dashboard and in triggering rules.

{% hint style="info" %}
Not every standard event is available as a dashboard trigger today — if you need one for campaign targeting, confirm it appears in the trigger picker before relying on it.
{% endhint %}

### Custom events (developer-emitted)

Custom events are the ones your app decides to track. They are the vocabulary your product team uses to describe what users do: `OnboardingCompleted`, `PaywallViewed`, `SearchPerformed`, `TrialStarted`.

Custom events are recorded through the SDK's `track()` API. The PM picks the name and which properties are useful; the developer wires up the call at the right moment in the app.

Plain-English example of how it lands in the dashboard:

> When a user fires the `PurchaseCompleted` event with property `plan = "annual"`, and it's their first time this session, open the thank-you deeplink.

See [Tracking events](/developer-guide/tracking-events) for how to call `track()` on each platform.

## Properties on events

Events carry properties. Properties take primitive values — strings, numbers, booleans. Keep property maps flat; nested objects are not supported.

A good property map describes the instance:

* `product_id: "sku_123"`
* `category: "widgets"`
* `price: 29.99`
* `source: "search_results"`

A campaign trigger can condition on property values: "fire only when `plan = annual`". See [Targeting and audiences](/concepts/targeting-and-audiences) for the condition vocabulary.

## Naming conventions

Consistency is more valuable than cleverness.

* Event names use `PascalCase`, property keys use `snake_case`. Keep both consistent across all custom events.
* Name events as things-that-happened, not screens: `PurchaseCompleted` beats `PurchaseScreen`.
* Keep names specific: `OnboardingStepCompleted` with a `step` property is better than `OnboardingStep1Completed`, `OnboardingStep2Completed`, etc.

## Events feed triggers

Every event, standard or custom, is available to the rule builder as a **trigger** — "when this event fires, evaluate this campaign". The trigger picker reads from the live Events catalog (see [Sessions and events (user guide)](/user-guide/sessions-and-events)).

To target on prior behavior — "users who already did X" — track an event when X happens and **also** set a custom property on the user (for example, `has_completed_onboarding = true`). The rule builder targets on the custom property; the event is what your app sets it from. See [User attributes](/concepts/user-attributes).

## What Amply does not promise about events

* Amply is not your primary product analytics tool. It records enough to evaluate rules and power targeting; it does not replace Amplitude, Mixpanel, or PostHog.
* Event delivery is best-effort and batched. Exact arrival ordering across devices is not guaranteed.

## Related

* [Tracking events](/developer-guide/tracking-events) — how to call `track()` in iOS, Android, and React Native
* [Targeting and audiences](/concepts/targeting-and-audiences) — how events become audience rules
* [Campaign delivery](/concepts/campaign-delivery) — how standard and custom events drive triggers and frequency
* [User attributes](/concepts/user-attributes) — the other half of what Amply knows about a user
* [Sessions and events (user guide)](/user-guide/sessions-and-events) — reading the event stream in the dashboard


# Targeting and audiences

How Amply decides who sees what. A campaign reaches a user only when that user matches the campaign's conditions. This page explains the vocabulary: rules, operators, audiences.

## Rules are built from conditions

A rule is a list of conditions you combine in the rule builder. Each condition compares one attribute against one value using one operator.

Plain example:

> Users whose country is France AND whose `subscription_status` equals `premium` AND who installed the app in the last 30 days.

That is three conditions joined with AND. Each condition follows the same shape:

```
<attribute>           <operator>          <value>
country               equals              France
subscription_status   equals              premium
install date          is less than        30 days ago
```

You build this in the dashboard's rule builder by adding conditions one at a time. Conditions are joined with AND — every condition must pass for a user to match. Within a single condition, list-style operators give you an internal OR — "country is one of France, Germany, Italy" matches a user in any of the three.

## Attributes you can target on

Attributes are grouped by where they come from. See [User attributes](/concepts/user-attributes) for the full picture; here is what you can use in targeting conditions today:

* **Country** — the device's country.
* **OS version** — the device OS version (for example, iOS 17).
* **App version** — the current app version installed on the device.
* **App install version** — the version of the app at install time (useful for "only users who installed after v3.0").
* **Application** — which app in your project (include or exclude a specific app).
* **Install date** — when the user first installed the app. Works as an absolute date ("installed after 2026-01-01") or a relative one ("installed in the last 7 days").
* **Event history** — what the user has actually done in your app: how many times they've performed an event, whether they've ever done it, and when they first or last did it. Optionally narrowed by event properties ("`Purchase` where `product` equals `pro`"). See [Targeting on past behavior](#targeting-on-past-behavior) below.
* **Custom properties** — values you set on the user from your app code (plan tier, premium status, internal user type).

For the full list and when to use which, see [User attributes](/concepts/user-attributes).

## Operators

The operator set changes slightly by attribute type, but the vocabulary is small.

**Numeric and version comparisons:**

| Operator         | Meaning                  |
| ---------------- | ------------------------ |
| equals           | exactly equal            |
| not equal        | anything but this value  |
| greater than     | strictly greater         |
| less than        | strictly less            |
| greater or equal | greater than or the same |
| less or equal    | less than or the same    |

**Existence checks:**

| Operator   | Meaning                                 |
| ---------- | --------------------------------------- |
| is set     | the user has a value for this attribute |
| is not set | the user has no value                   |

**List membership (for country, application):**

| Operator | Meaning                                        |
| -------- | ---------------------------------------------- |
| include  | match when the user's value is in the list     |
| exclude  | match when the user's value is NOT in the list |

**Dates:**

Dates support absolute ("on or after 2026-01-01") and relative ("in the last 30 days") forms. Absolute comparisons work in whole calendar days (UTC) — "install date is 2026-01-01" matches any moment during that day. Relative comparisons count rolling 24-hour windows back from now, not calendar days — "in the last 7 days" means the last 168 hours.

## Audience — the result of a rule

An audience is the set of users who currently match a rule. It is not a stored list. It is evaluated on-device every time a campaign is considered, using the latest attributes. A user who was in an audience yesterday can drop out today (for example, the user updated to a new app version, or changed country on vacation).

This matters: audiences are live. You do not "refresh" or "rebuild" them. A campaign that says "users in France on app version ≥ 5.0.0" will immediately pick up a French user the moment they upgrade.

## Targeting on past behavior

Audience rules can target events directly. An **Event** condition matches users by what they have done since installing the app — no extra code, no custom property to maintain.

An Event condition can check three things, alone or combined:

* **How many times.** "Did `Purchase` at least 3 times", "did `WorkoutCompleted` fewer than 5 times". Operators: exactly, not exactly, more than, fewer than, at least, at most — plus the simple forms **has happened (ever)** and **has never happened**.
* **When — first or last occurrence.** Relative: "last `WorkoutCompleted` was more than 14 days ago" (with an explicit "…or never" variant) or "first `Purchase` was within the last 7 days". Absolute: before or after a specific date. Absolute dates cover whole calendar days (UTC); relative "days" are rolling 24-hour windows, not calendar days.
* **With matching properties.** Narrow the condition to occurrences whose properties match — "`Purchase` where `product` equals `pro`". Comparisons: equals or not equals; number values also support greater and less. Only matching occurrences count, both for counts and for first/last dates.

Event history starts at install, and Event conditions behave like every other condition: evaluated on the device, live, joined with AND, and working offline.

{% hint style="info" %}
Event conditions require apps built with SDK 0.6.1 or later. On older app builds, a campaign that uses an Event condition simply never matches — nothing breaks, the campaign just stays silent for those users.
{% endhint %}

### When a custom property still fits

For derived state that isn't a single event — a computed status, a combination of several signals, or something your backend decides — set a custom property on the user and target that instead. The rule builder targets on the property; the event is the trigger that lets your code set it.

> When your backend flags an account as at-risk, call `setCustomProperty("account_status", "at_risk")`. A campaign for those users targets `account_status = "at_risk"` — something no single event count could express.

Either way, the result is a rich audience composed of attribute, event, and property conditions, all joined with AND. See [Campaign delivery](/concepts/campaign-delivery) for how triggers then decide when the campaign actually fires for those users.

## Targeting vs. triggering

These are often confused.

* **Targeting** decides who is eligible. "Is this user a match for this campaign?"
* **Triggering** decides when the eligible user sees it. "Has the right thing just happened?"

A user can be targeted by ten campaigns but only one triggers on any given event. See [Scenarios and campaigns](/concepts/scenarios-and-campaigns) and [Campaign delivery](/concepts/campaign-delivery) for how the two layers interact.

## Practical examples

* **"France or Germany, premium users"** — country IN \[France, Germany] AND custom property `plan = "premium"`.
* **"New users on the latest app version"** — install date in the last 7 days AND app version >= 3.4.0.
* **"Lapsing premium users"** — custom property `plan = "premium"` AND custom property `trial_ends_at` is within the next 3 days.
* **"Users who never finished onboarding"** — `OnboardingCompleted` has never happened (or, if you also maintain a property: `has_onboarded` is not set / equals `false`).
* **"Repeat buyers"** — did `Purchase` at least 3 times.
* **"Lapsed workout loggers — win-back"** — last `WorkoutCompleted` was more than 14 days ago.
* **"Never tried export"** — `ExportClicked` has never happened.

## Related

* [User attributes](/concepts/user-attributes) — the attributes you can build conditions from
* [Sessions and events](/concepts/sessions-and-events) — where event-based conditions come from
* [Campaign delivery](/concepts/campaign-delivery) — how triggering combines with targeting
* [Targeting an audience](/user-guide/targeting-an-audience) — step-by-step in the dashboard
* [User attributes (dev)](/developer-guide/user-attributes) — setting custom properties from app code


# User attributes

What Amply knows about a user. Attributes come from three places: the device itself, the app install, and what your code tells Amply. Targeting rules, frequency caps, and campaign conditions are all built on top of these.

## Three sources of attributes

| Source            | Who sets it            | Examples                                                  |
| ----------------- | ---------------------- | --------------------------------------------------------- |
| Device attributes | The SDK, automatically | Platform, OS version, country, device model               |
| App attributes    | The SDK, automatically | Install date, app version at install, current app version |
| User attributes   | You, from app code     | User ID, plan tier, premium status, account age           |

You rarely think about the boundary when you are writing rules in the dashboard — the rule builder shows all three groups together. But it helps to know which is which when you are asking "can I target on this?"

Alongside attributes, rules can also target **event history** — how many times an event has happened for a user, and when it first or last happened. That works off the events you already track, with nothing extra to set. See [Targeting and audiences](/concepts/targeting-and-audiences#targeting-on-past-behavior).

## Device attributes — automatic

The SDK collects these from the platform. You do not need to write any code.

* **Platform** — iOS or Android.
* **OS version** — the operating system version.
* **Country** — derived from the device locale.
* **Application** — which of your apps this is (relevant for projects with more than one app).

Device attributes describe the environment. They are good for rolling out a campaign only on one platform, or gating on a minimum OS version.

## App attributes — automatic

These describe the install and the usage.

* **Install date** — when the app was first installed on this device.
* **App install version** — the app version at install time. Useful for distinguishing long-time users from recent installs.
* **App version** — the app version currently running.

App attributes describe the user's relationship to your app over time. They let you ask "is this a brand-new user or a returning one?", "which cohort did they install into?"

> **Note — session count.** The SDK tracks session count internally, but it is not currently exposed as a built-in targeting attribute in the rule builder. To target on session count, set it as a custom property from your app on every session start — see [Sessions and events](/concepts/sessions-and-events).

## User attributes — you set these

This is the only group your code controls. There are two calls you need to know:

* **`setUserId(userId)`** — attach a stable identifier to this device. Call it when the user logs in. Call it with `null` to clear on logout.
* **`setCustomProperty(key, value)`** — store a named value for this user. Call it whenever the value changes.

Custom properties take primitive values. The SDK supports strings, numbers, and booleans on every platform. The iOS and Android native SDKs additionally support date/time values. Nested objects and arrays are not supported — keep values flat.

Examples of custom properties worth setting:

* `plan` — "free", "pro", "team"
* `premium` — true / false
* `trial_ends_at` — a date
* `signup_source` — "organic", "paid\_ads", "referral"
* `account_tier` — 1, 2, 3

Custom properties are the bridge between your app's internal state and Amply's targeting. If you want to target "users on the pro plan", someone has to call `setCustomProperty("plan", "pro")` when the user's plan becomes pro.

## When to use which

* You want to roll out to a platform or OS → **device attribute**.
* You want to gate on "how long have they used the app" → **app attribute** (install date) or a custom property (session count, time-in-app).
* You want to gate on a product concept your app owns (subscription state, account type, premium tier, experiment cohort) → **user attribute** (custom property).

Rule of thumb: if the value lives inside your app's business logic, it is a custom property. If it comes from the device or the install, the SDK has it for free.

## Identifying the same user across installs

`setUserId` is how you keep targeting stable when the user reinstalls, moves devices, or logs in on a second device. The device is what Amply sees by default; `setUserId` gives you a stable handle on top of that.

Set the user ID as soon as you have one. Clear it (pass `null`) on logout so the next user on the same device does not inherit the previous user's properties.

## Custom property lifecycle

Custom properties persist on the device. Once you set a property, it stays until:

* You call `setCustomProperty` again with the same key (overwrite).
* You call `removeCustomProperty(key)` (delete one).
* You call `clearCustomProperties()` (delete all).
* The user uninstalls the app.

The persistence means you do not need to re-set properties on every app launch. Set them when they change.

## React to a property changing

Targeting on a custom property answers "who is this user *right now*." But sometimes the moment of change is the opportunity. Every time a custom property is set, updated, removed, or cleared, Amply emits a `CustomPropertyChanged` event — and you can use that event as a campaign **trigger**.

That means you can react to the instant a trait flips, not just to its current value. For example, fire a winback the moment `plan_tier` becomes `free` — the change itself is the signal, so the campaign runs at exactly the right beat instead of waiting for the next session or event. The event carries the property key plus its old and new value, so a trigger can condition on the direction of the change. See [Events](/reference/events) for the exact parameters.

See [Tracking events](/developer-guide/tracking-events) for event tracking, and [User attributes (dev)](/developer-guide/user-attributes) for the exact signatures.

## What is already there vs. what you have to set

| Attribute           | Automatic?                 | Notes                                                |
| ------------------- | -------------------------- | ---------------------------------------------------- |
| Platform            | yes                        | iOS / Android                                        |
| OS version          | yes                        |                                                      |
| Country             | yes                        | derived from locale                                  |
| App version         | yes                        | current version                                      |
| App install version | yes                        | the version at install time                          |
| Install date        | yes                        | first install                                        |
| Session counter     | tracked but not targetable | set as a custom property if you need to target on it |
| User ID             | no                         | call `setUserId` on login                            |
| Custom properties   | no                         | call `setCustomProperty` whenever values change      |

## Related

* [Targeting and audiences](/concepts/targeting-and-audiences) — how attributes become rules
* [Sessions and events](/concepts/sessions-and-events) — the other half of what Amply knows
* [User attributes (dev)](/developer-guide/user-attributes) — SDK calls in code
* [Tracking events](/developer-guide/tracking-events) — complementary: events describe behavior, attributes describe identity


# Campaign delivery

How an active campaign actually reaches a user. This page covers triggers (what causes a campaign to fire), frequency (how often it can fire for the same user), and rollout (how you take a campaign live safely and roll back instantly if something is wrong).

## The delivery model in one paragraph

Amply ships a configuration of active campaigns to each device. The SDK evaluates that configuration locally on every event. When an event matches a campaign's trigger, and the user passes the campaign's targeting, and the frequency rules allow it, the campaign's action runs on the device — an external URL opens, a deeplink routes to one of your screens, or the native rate prompt appears. Because the configuration is delivered from the server, you change campaigns without releasing the app.

## Triggers — what causes evaluation

A trigger is an event plus optional conditions on that event's properties. Every active campaign has exactly one trigger.

The event can be a standard event emitted by the SDK, or a custom event you call `track()` on. See [Sessions and events](/concepts/sessions-and-events) for the full list.

### Session-start campaigns

Set the trigger event to the standard `SessionStarted` event. The campaign is considered every time the user opens the app.

Common uses:

* Show a paywall on session N.
* Reactivate users whose last session was more than 7 days ago.
* Route first-time users into an onboarding variant.

### Event-triggered campaigns

Set the trigger event to any custom event you track in your app.

Common uses:

* Trigger a rate prompt after `PurchaseCompleted`.
* Open a feature-discovery deeplink after `CoreActionCompleted`.
* Show a winback deeplink when `CheckoutAbandoned` fires.

### Event-property conditions on the trigger

A trigger can filter on the event's properties. For example, the trigger `PurchaseCompleted` can additionally require `plan == "annual"`. This is separate from audience targeting (which filters on the user). Property conditions filter on the event occurrence.

## Frequency — how often a campaign can fire

Amply models frequency in two parts: a repeat rule and a limit. Together they answer "at which occurrences of the trigger should the campaign fire, and what is the overall cap?"

### Repeat rule — which occurrences fire

Two repeat modes, applied against either event count or session count:

* **Every N** — fire on every Nth occurrence. "Every 3rd session", "every 5th `FeatureViewed`".
* **Interval** — fire on specific occurrence numbers from a list. "On sessions 2, 4 and 7" or "on the 1st and 10th `SearchPerformed`".

The "entity" picks what you are counting:

* **event** — count all occurrences of this event across the user's history.
* **session** — count occurrences within the current session only.

Plain-English examples:

* "Show on every 3rd session from session 3 onwards" — repeat type = every, entity = session, value = 3.
* "Show on session 2 and session 5 only" — repeat type = interval, entity = session, value = \[2, 5].
* "Show every 10th `PurchaseCompleted`" — repeat type = every, entity = event, value = 10.

### Limit — the overall cap

A limit caps total impressions over a window:

* **Count per device or per session** — "at most 3 times, ever" or "at most 1 time in this session".
* **Rate limit over a time window** — "at most 1 impression per day", "at most 2 per hour".

Limits are evaluated in addition to the repeat rule. Even if the repeat rule matches, the limit can block the impression.

## Rollout controls

Rollout is how you take a campaign from "it looks right in the dashboard" to "it is live for everyone" without surprises.

### Audience targeting as rollout

The simplest rollout is to narrow the audience. Start active with a tight audience (for example: internal testers identified by a custom property, one country, one app version), verify impressions, then widen by editing the targeting rule.

Because targeting is evaluated live, broadening the audience instantly adds users to eligibility; narrowing it instantly removes them.

### State-based rollout

Every campaign has a state: Draft, Active, or Cancel. See [Scenarios and campaigns](/concepts/scenarios-and-campaigns) for the lifecycle.

* While editing, keep the campaign in **Draft**. No device sees it.
* When ready, flip to **Active**. It goes live on every device within moments — no app release.
* To stop it, flip to **Cancel**. It stops firing on every device within moments.

### Instant rollback

Rolling back a bad campaign is the same as flipping it to **Cancel** in the dashboard — it stops firing on every device within moments. You do not need an app release, a code change, or a feature-flag provider.

This is the core promise: in-app behavior is editable without a release.

## Order of evaluation

When an event fires on a device, the SDK:

1. Finds every active campaign whose trigger event matches.
2. For each match, evaluates the campaign's targeting against the current user.
3. For each targeted match, evaluates the frequency rules (repeat + limit).
4. Runs the action for the first campaign that passes all three.

Only one campaign fires per event. Order your campaigns so that the more specific, more time-sensitive campaigns sit above the fallback ones.

## What happens if the SDK can't reach the server

If the device is offline, the SDK uses the most recently fetched configuration. Campaigns already known to the device continue to evaluate. A newly activated campaign on the server reaches the device only on its next successful fetch.

For the practical side — previewing before going live, staged rollout, rolling back — see [Testing and rollout](/user-guide/testing-and-rollout).

## Related

* [Scenarios and campaigns](/concepts/scenarios-and-campaigns) — the Draft / Active / Cancel lifecycle
* [Sessions and events](/concepts/sessions-and-events) — what events you can trigger on
* [Targeting and audiences](/concepts/targeting-and-audiences) — the Who layer
* [Campaign actions](/concepts/campaign-actions) — what actually runs when the campaign fires
* [Testing and rollout](/user-guide/testing-and-rollout) — how-to for safe deployment
* [Creating a campaign](/user-guide/creating-a-campaign) — end-to-end dashboard walkthrough


# Campaign actions

What a campaign does when it fires. Every campaign has a type that determines the action Amply performs on the device at the moment the trigger matches. The set of action types grows over time as new campaign types ship.

## Action types

### Deeplink

Routes the user to a screen or URL. When the campaign fires, the SDK calls the deeplink listener your app registered at startup and passes the URL along. Your app handles the routing — the screen you navigate to, how it looks, and what the user does there are all under your control. This is fire-and-forget: Amply sends the URL and the SDK calls your listener; no outcome flows back to the campaign.

The URL is a string you control. Two common conventions:

* **Your app's own scheme** — for example `yourapp://promo` or `yourapp://feature-discovery`. This is the same scheme you would use for push-notification deeplinks or external launches. Amply routes through it the same way.
* **A dedicated scheme for campaign-triggered routes** — the examples in these docs use `yourapp://` as a sample convention (for example `yourapp://campaign/promo/abc123`). This is not a reserved namespace — use whatever scheme or pattern makes sense in your app. A separate scheme can make it easy to distinguish campaign-triggered navigation from ordinary app-wide deeplinks.

What a deeplink action is good for:

* Routing new users to a specific onboarding screen based on the ad they came from.
* Opening a feature-discovery walkthrough after a user completes their first core action.
* Showing your paywall screen tied to a specific feature attempt.
* Returning lapsed users to a "welcome back" screen with a tailored offer.
* Triggering an in-app popup screen you render yourself (the screen is just a route your app already has).

In each case, the screen already exists in your app. Amply decides who sees it and when; your app renders it.

See [Handling deeplinks](/developer-guide/handling-deeplinks) for the SDK calls on iOS, Android, and React Native, and [Managing deeplinks](/user-guide/managing-deeplinks) for the dashboard side.

### Native rate prompt

Triggers the platform's native rating dialog — `SKStoreReviewController` on iOS, the in-app review dialog on Android. This is fire-and-forget: the SDK presents the system prompt and no outcome flows back to the campaign. Both platforms rate-limit and control the prompt tightly; you cannot trigger it at will, and you cannot style it.

Use the RateReview campaign type when:

* You want the platform's native prompt at a specific moment without any prior custom screen.
* The moment is determined by a meaningful event (a successful purchase, a milestone).

Gate it carefully with targeting and frequency rules. The platform will suppress prompts shown too often; a campaign that over-prompts will silently stop working on the platform side.

For a two-step pattern — first ask the user if they are happy, then route to the native prompt only if they are — fire a custom event such as `PositiveRatingShared` as the RateReview campaign's trigger. See [Rate-review flows](/user-guide/rate-review-flows) for the full pattern.

### Gated action

An action that produces an outcome the app waits for before continuing. Instead of fire-and-forget, the app calls `trackGated` at a moment it has registered as a gate; Amply runs the campaign action at that moment and the SDK returns the outcome to the app once the action resolves — for example, after a rewarded ad completes or a user dismisses a prompt.

See [Gating an action](/concepts/gating-an-action) for the concept and how to use outcomes in your app flow.

## Related

* [Handling deeplinks](/developer-guide/handling-deeplinks) — SDK calls to register the deeplink listener (iOS, Android, React Native)
* [Managing deeplinks](/user-guide/managing-deeplinks) — dashboard side for deeplink campaigns
* [Rate-review flows](/user-guide/rate-review-flows) — native rate prompt patterns
* [Custom popups](/user-guide/custom-popups) — in-app popup pattern via deeplink
* [Gating an action](/concepts/gating-an-action) — gated action concept and API
* [Campaign delivery](/concepts/campaign-delivery) — triggers and frequency that drive when actions fire
* [Scenarios and campaigns](/concepts/scenarios-and-campaigns) — how campaign actions compose into scenarios


# Gating an action

Some moments in your app are worth pausing for. **Gating** lets a campaign make the app **wait for something to finish** — a rewarded ad, a quick survey, an upgrade screen — before the user's action goes through, and then continue or back out depending on how it went.

Most campaigns are fire-and-forget: they fire an action and the app keeps moving. A gate is the opposite — it holds the user's next step until the gated action resolves.

## The two halves

* **The app declares the gate-able moments.** A developer marks specific points — "Save", "Export", "Start sync", "Apply filter" — as moments the app is willing to pause at. This is a one-time setup per capability.
* **You compose the campaign.** From the dashboard you attach a campaign to one of those moments — its audience, its cadence, and the action it runs — and it starts gating. Pointing the gate at a different moment the app already exposed is a dashboard change, not a release.

## Use cases

**Watch a rewarded ad to unlock an action.** A free user taps **Export**. The campaign gates it on a rewarded ad: they watch it through → the export runs; they close it early → the export doesn't happen. You monetize a high-intent moment without a hard paywall.

**A one-tap survey before a big step.** Before **Cancel subscription** or **Delete account**, gate on a short reason picker. They pick a reason → the flow continues; they back out → they stay. You capture churn reasons exactly where intent is highest.

**A soft upgrade nudge at a premium moment.** A free user taps a pro-only filter. Gate on an upgrade screen: they upgrade → the filter applies; they dismiss → they fall back to the free version. A contextual upsell at the exact point of desire. (Your app still grants the actual entitlement — the gate shapes the moment, see *When not to gate*.)

**A value-exchange unlock.** "Invite 3 friends to unlock this theme" — gate the unlock on the invite flow completing, so the reward lands the instant the condition is met.

## When not to gate

Gating is for moments where waiting makes sense. Two things should **not** be gated:

* **Permission and consent prompts** — tracking permission, privacy consent, push permission. Don't wire these as gates. Show them as ordinary prompts, record the answer as a user attribute for targeting later, and let the user's action continue regardless. Making a core flow wait on a consent answer risks freezing the user on a prompt.
* **Opportunistic, informational, or navigational actions** — a rate-and-review prompt, a promo screen, an announcement. These should fire and let the app move on.

A simple test: gate it only if "the user deliberately didn't finish this" is a meaningful outcome you'd want to act on. For an ad or a survey, it is. For a consent prompt or a promo, it isn't.

## The safety promise

A gate **never traps the user behind a broken dependency.** If the ad fails to load, the network drops, or the app isn't ready, the user's action goes through anyway. The only time an action is held back is a deliberate "the user chose not to complete it" — for example, closing a rewarded ad before finishing — and only when the campaign is set to treat that as a cancel.

Because of that promise, **a gate is a UX tool, not a lock.** Don't use it as the thing that grants an entitlement or enforces a payment — grant the premium feature in your app or through the store as you normally would, and use the gate to shape the moment around it.

## What you control without a release

Once the app has exposed a gate-able moment, from the dashboard you control: **which campaign gates it, who it targets, how often it fires, what the gated action shows** (for example, a rewarded ad's reward amount or length), and **whether closing it cancels or lets the flow proceed.** Pointing a gate at a different moment the app already exposed needs no release.

## Related

* [Handling deeplinks](/developer-guide/handling-deeplinks) — how a developer wires the app's gate-able moments and the action that runs at each
* [Tracking events](/developer-guide/tracking-events) — the gate lives at an event the app already tracks
* [Campaign delivery](/concepts/campaign-delivery) — how Amply decides which campaign fires at a moment
* [Campaign actions](/concepts/campaign-actions) — the fire-and-forget actions a gate sits alongside


# Overview

A high-level tour of what you can do in the Amply dashboard without writing code. Use this to find the right screen for the task, then click through to the deep page.

**Use this when** you're new to Amply and want to know where things live, or when you're orienting a new teammate. **Don't use this when** you already know what you want to do — go straight to the task-specific page.

## Starting point

After you sign in, the dashboard is organized around four things, top to bottom:

1. **Projects** — the workspace for an app or a group of apps
2. **Applications** — the individual iOS or Android apps inside a project
3. **Campaigns** — what you actually ship to users
4. **Events** — what users and the SDK report back

Billing, team members, and profile settings live alongside these, under the same admin area.

## Projects

A project is the container for everything else. Each project has its own applications, campaigns, and event history. Most teams keep one project per product.

* **Create or switch project:** project list in the admin area
* **Rename, archive, or configure:** project edit screen

See: [Dashboard tour — Create your first project](/user-guide/dashboard-tour#create-your-first-project)

## Applications

An application represents a shipping app binary — typically one per platform. A project can have several applications (for example, an iOS build and an Android build of the same product).

The application is also where the SDK's API key lives. If a developer needs credentials to wire the SDK into an app, you'll generate them here and hand them over.

* **List / add / edit applications:** applications screen inside the project

See: [Dashboard tour — Add your iOS or Android application](/user-guide/dashboard-tour#add-your-ios-or-android-application)

## Campaigns

Campaigns are the main unit of work in Amply. A campaign decides who sees something in the app, when it fires, and what happens when it does — a deep link, a rate-and-review prompt, or a [gated action](/concepts/gating-an-action) the app waits on (like a rewarded ad before an export) before the user's step goes through.

From the Campaigns screen you can:

* Filter by state: **All**, **Draft**, **Active**, **Cancel**
* Create a new campaign via **New campaign**
* Open a campaign to view, edit, change state, or delete it

A campaign is built in steps: **Who** (audience), **When** (trigger event and frequency), and for Deep Link campaigns, **What** (the URL to open).

See: [Creating a campaign](/user-guide/creating-a-campaign), [Campaigns](/user-guide/campaigns), [Targeting an audience](/user-guide/targeting-an-audience)

## Events

The Events screen is a catalog of every event name the SDK has seen for this project, across all devices — both SDK-generated events and your own custom events. You'll use it for three things:

* Confirming that a new event your engineer just added is actually arriving
* Checking when an event was last seen on iOS vs Android
* Turning an event on or off for campaign use, per event

Events are the raw material for campaign triggers and for audience rules that look at past behavior.

See: [Sessions and events](/user-guide/sessions-and-events)

## Team and access

Under team settings you can:

* Invite teammates to the organization
* Manage who has access to which project

See: [Dashboard tour — Invite teammates](/user-guide/dashboard-tour#invite-teammates)

## Billing

Billing covers your organization's plan, payment method, invoices, and plan upgrades. If you're on a free plan, some advanced features (for example, price management tooling) are hidden until you upgrade.

See: [Dashboard tour — Upgrade the plan](/user-guide/dashboard-tour#upgrade-the-plan)

## Price management

If your plan includes it, a **Price Management** entry appears in the navigation. Use it to plan, preview, and submit price changes for your in-app subscriptions and one-time products across every country the app sells in — right from the dashboard, no code required.

See: [Price management](/price-management/price-management)

## Profile and organization

The profile screen holds your personal details and password. The organization screen holds your organization name and org-wide settings.

See: [Dashboard tour — Two areas](/user-guide/dashboard-tour#two-areas)

## Recurring tasks

Day-to-day work in the dashboard usually involves:

1. Opening **Campaigns** to review what's Active.
2. Creating or editing a campaign — audience, trigger, content — and moving it from Draft to Active.
3. Checking **Events** to confirm the triggering event is arriving from real devices.
4. Adjusting targeting or frequency when a campaign fires too often or too rarely.

## Related

* [Creating a campaign](/user-guide/creating-a-campaign) — end-to-end walkthrough
* [Targeting an audience](/user-guide/targeting-an-audience) — the rule builder, explained
* [Campaigns](/user-guide/campaigns) — campaign types and options
* [Sessions and events](/user-guide/sessions-and-events) — what the events screen tells you


# Dashboard tour

The first 20 minutes after sign-up: create a project, register your app, hand the SDK key to your engineer, build a campaign, and verify it's firing. Each step links to a deeper how-to.

**Use this when** you're new to Amply and want a guided path from empty dashboard to first live campaign. **Don't use this when** you already know the workflow — jump straight to [Creating a campaign](/user-guide/creating-a-campaign) or [Price management](/price-management/price-management).

## Two areas

The dashboard is split in two:

* **Project sidebar** (left) — project-scoped: **Dashboard**, **Campaigns**, **Events**, **Price Management** (gated), **Builder** (gated). Switching projects swaps the contents.
* **Avatar menu** (top right) — organization-scoped (**Organization settings**, **Projects**, **Teams & Members**, **Billing**) plus your personal **Profile settings**. Stays the same wherever you are.

## Create your first project

A project is the workspace for one product. Create more projects when you need to keep separate apps' data, campaigns, and events apart.

1. From the avatar menu, open **Projects**.
2. Click **New project** in the top right. The form heading reads **Create a new project**.
3. Fill in **Project name** and click **Create Project**.

You can switch between projects from the same Projects screen.

## Add your iOS or Android application

Each binary you ship is one **application**. A project can hold several — typically one iOS and one Android.

1. From the Projects list, click your project to open it.
2. On the **Applications** screen, click **New application**. Heading: **Create a new application**.
3. Fill in:
   * **Application Name** — what you'll see in dropdowns later.
   * **Bundle Id** — the iOS bundle identifier or the Android package name (e.g. `com.example.app`).
   * **Platform** — **iOS** or **Android**.
4. Click **Create Application**.

Repeat for the second platform. Both end up in the same Applications list.

## Hand the SDK key to your engineer

Each application has its own pair of API keys (public + secret) that the SDK uses to identify the device and authenticate calls.

1. From the Applications list, click your application to open the edit screen. Heading: **Edit "\<app name>"**.
2. Open the **API Keys** tab (third tab, alongside General and Integrations).
3. The table shows **Public key**, **Secret key**, and **Last used**. Each row has a copy icon and a toast confirms when the value is on your clipboard.
4. If the table is empty, click **Add Api Key** in the top right of the page (only visible while you're on this tab).
5. Send the public + secret pair to your engineer. They'll need both for the SDK config — see [Installation](/developer-guide/installation).

You can rotate keys later — the row menu offers a **Revoke** action; just generate a new pair first so the SDK never has zero valid keys.

## Create your first campaign

A campaign decides who sees something in the app, when it fires, and (for Deep Links) what URL opens.

1. From the project sidebar, open **Campaigns**.
2. Click **New campaign**. The form has two steps for **Rate & Review** (**Who** and **When**) and three steps for **Deep Link** (adds **What**).
3. On the last step, click **Finish**. The campaign saves as **Draft**.
4. Open the new campaign and click **Start campaign** to activate it.

Full walk-through: [Creating a campaign](/user-guide/creating-a-campaign). For options each campaign type supports, see [Campaigns](/user-guide/campaigns).

A campaign saved as Draft does nothing until you start it.

## Verify it's firing in Events

Events is where you confirm the SDK is reporting what you expect — useful both right after wiring up the SDK and after every campaign change.

1. Open **Events** from the project sidebar.
2. The table lists every event name the SDK has reported, with **Last Seen** per platform (iOS / Android icon + timestamp) and an **available** toggle.
3. After you trigger your campaign on a test device, refresh and check that the event row's Last Seen updated.
4. If a custom event isn't in the list yet, the SDK hasn't sent it from any device in this project — trigger it in the app first.

The **available** toggle controls whether an event appears in the campaign trigger dropdown — flip it off for noisy or internal events you never want to target.

## Invite teammates

To add someone to the organization, open **Teams & Members** from the avatar menu. The screen shows everyone in the org, filterable by **All / Active / Pending / Banned / Rejected** status, with **Name**, **Role**, and **Status** columns.

The exact invitation flow depends on how your account was set up — if you don't see an obvious **Invite** action, contact Amply support.

## Upgrade the plan

Some features (Price Management, Builder) only appear once your plan includes them. To change plans:

1. Open **Billing** from the avatar menu.
2. On the free plan you'll see the available **plans** alongside your current one. On a paid plan you'll see your current plan and **Invoices**, with an **Upgrade** action that takes you to a separate plan-change view.
3. Pick the new plan and follow the checkout to confirm.

After the change goes through, the gated sidebar items become available.

## Related

* [Overview](/user-guide/overview) — what each area is for, conceptually
* [Creating a campaign](/user-guide/creating-a-campaign) — the next deep dive
* [Price management](/price-management/price-management) — if your plan includes it
* [Sessions and events](/user-guide/sessions-and-events) — what the Events screen is for
* [Installation](/developer-guide/installation) — what your engineer does with the API key


# Creating a campaign

How to build and ship a campaign from scratch, using the multi-step form in the dashboard. No code required.

**Use this when** you want to send users to a specific URL, ask them to rate the app, or (soon) block a flow — based on their behavior. **Don't use this when** you're debugging event delivery or auditing past campaigns — use the [events screen](/user-guide/sessions-and-events) and the campaign list instead.

**The faster path:** with [AI-assisted integration](/getting-started/ai-assisted-integration) set up, describe the campaign to your AI assistant in plain language and review the draft it builds — no form-filling. This page is the manual path.

## Before you start

You'll need:

* A project with at least one application already added
* An event name in mind for the trigger (for example, `OnboardingCompleted`, `PaywallViewed`) — either a standard SDK event or a custom event your app already sends
* For Deep Link campaigns: the destination URL

If the triggering event isn't arriving yet, open the [Events](/user-guide/sessions-and-events) screen first and confirm it's listed for at least one platform. A campaign that listens for an event that never fires will simply never fire.

## Step-by-step

{% stepper %}
{% step %}

### Open the campaign list

From the project, open **Campaigns**. You'll see tabs for **All**, **Draft**, **Active**, and **Cancel**. Click **New campaign** in the top right.
{% endstep %}

{% step %}

### Name it and pick a type

Enter a **Campaign name** — something a teammate will recognize in the list a month from now (for example, `Trial ended — reactivation deep link`).

Pick a campaign type:

* **Rate & Review** — prompts the user to rate your app using the platform's native review dialog
* **Deep Link** — opens a URL in your app or in the browser
* **Blocking** — labelled **Soon**; not yet available

The type decides what later steps you'll see. Deep Link adds a **What** step for the URL. Rate & Review does not.
{% endstep %}

{% step %}

### Who — define the audience

Start with **All users**. Narrow it down by clicking **Add rule** and picking a condition. Available conditions:

* **Countries** — include or exclude a list of countries
* **OS Version** — match on operating system version
* **App Version** — match on the app version currently installed
* **App Install Version** — match on the app version the user originally installed
* **Applications** — include or exclude specific applications in the project
* **Custom Property** — match on a property you set via the SDK (string, number, boolean, or datetime)
* **Install Date** — match on when the user first installed the app, as an absolute date or relative ("less than 7 days ago")

Rules combine with **And** — all of them must match. See [Targeting an audience](/user-guide/targeting-an-audience) for patterns and examples.

Click **Continue** when the audience looks right.
{% endstep %}

{% step %}

### When — pick a trigger event and frequency

Under **Triggering Event**, choose the event the campaign listens for. The dropdown suggests event names the SDK has already seen in this project.

Optionally narrow the trigger further by adding **event parameters** — for example, only trigger when `screen_name` equals `paywall`. Parameters support string, number, and boolean comparisons.

Under **Repeat Rules**, choose how often the campaign is allowed to fire for a single user:

* **every** N occurrences of the event, **globally** or **in session**
* **on** specific occurrences (1st, 3rd, etc.)

Under **Frequency Limits**, cap how often the campaign can fire overall per session or per device, optionally over a time window (seconds, minutes, hours, or days).

Click **Continue**.
{% endstep %}

{% step %}

### What — set the content (Deep Link only)

If you picked **Deep Link**, enter the **DeepLink** URL the campaign should open when it fires. This can be:

* A regular `https://` link to open in the browser or in-app browser
* A custom scheme (for example, `yourapp://promo/spring`) your app already handles

For **Rate & Review**, there's nothing to configure — the platform's native review prompt is shown.

Click **Finish** to save the campaign.
{% endstep %}

{% step %}

### Activate it

A new campaign saves as **Draft**. From the campaign list or the campaign's detail screen, change its state to **Active**. Only Active campaigns evaluate against live traffic.

To pause a campaign, switch its state to **Cancel**. It stops firing immediately for new events.
{% endstep %}
{% endstepper %}

## After you ship

Spot-check the campaign in the first hour:

* Open the campaign from the list and check that the state is **Active**
* On a test device, reproduce the triggering event and confirm the deep link opens or the review prompt appears
* In [Events](/user-guide/sessions-and-events), confirm the triggering event is arriving from real devices — not just your test

How the app actually receives the deep link depends on the SDK integration. See the developer-guide pages for platform-specific listener setup: [Android](/developer-guide/android-integration), [iOS](/developer-guide/ios-integration).

## Common mistakes

* **Campaign saved as Draft and forgotten.** Draft campaigns don't fire. Flip to Active.
* **Triggering event name typo.** Pick from the dropdown whenever possible. If you type a name by hand, copy-paste from the event catalog.
* **Audience too narrow.** If nothing fires for a day, loosen the rules and re-test.
* **Rate & Review in a debug build.** On Android, the Play Store review prompt only appears in release builds distributed through Play. Debug shows a simulated dialog.

## Related

* [Targeting an audience](/user-guide/targeting-an-audience) — patterns for the Who step
* [Campaigns](/user-guide/campaigns) — campaign types and the options each one supports
* [Sessions and events](/user-guide/sessions-and-events) — confirm your trigger event is arriving
* [Campaign delivery](/concepts/campaign-delivery) — how a campaign evaluates against an event


# Campaigns

The dashboard campaign reference: how the list view is organized, the per-type options each campaign exposes, and the When-step mechanics for repeat rules and frequency limits.

> Reference for the dashboard. For the model — what a campaign is, how it composes into a scenario, the lifecycle — see [Scenarios and campaigns](/concepts/scenarios-and-campaigns). For step-by-step creation, see [Creating a campaign](/user-guide/creating-a-campaign).

## The campaign list

Open **Campaigns** in a project. The list shows every campaign in any state and offers four filter tabs:

* **All**
* **Draft**
* **Active**
* **Cancel**

Open a campaign to view, edit, change state, or delete it. The new-campaign action is **New campaign** at the top right.

What you can do per state:

| State  | State-change button on the detail screen | Other actions |
| ------ | ---------------------------------------- | ------------- |
| Draft  | **Start campaign** (moves to Active)     | edit, delete  |
| Active | **Stop campaign** (moves to Cancel)      | edit          |
| Cancel | **Start campaign** (re-activates)        | edit, delete  |

A state change propagates to every device on the next configuration fetch — see [Campaign delivery](/concepts/campaign-delivery).

## Campaign types and their options

### Rate & Review

Prompts the user to rate your app using the platform's native review dialog — the App Store review prompt on iOS, the Play Store in-app review on Android.

Use it for:

* Asking satisfied users to leave a review after a positive moment (completing a purchase, finishing onboarding, earning an achievement).

Options the form exposes:

* **Who** — audience rules. See [Targeting an audience](/user-guide/targeting-an-audience).
* **When** — triggering event, optional event-parameter filters, repeat rules, frequency limits.
* No **What** step — the platform dialog is fixed.

Notes:

* On Android, the Play Store's in-app review API only produces a real prompt in release builds distributed through Play. Debug builds show a simulated dialog.
* The platforms cap how often the prompt actually shows, regardless of your repeat rules. Treat "asked" as best-effort.

### Deep Link

Opens a URL when the campaign fires. The URL can be:

* A standard `https://` link (opens in the browser or an in-app web view, depending on how the app handles it).
* A custom URL scheme (for example, `yourapp://promo/spring`) handled by your app's deep-link code.

Use it for:

* Routing users into a specific in-app screen (paywall, offer, onboarding variant, feature tour).
* Sending users to a web destination (blog post, survey, support article).
* Triggering an in-app flow that's already wired up to a scheme your app owns.

Options the form exposes:

* **Who** — audience rules.
* **When** — triggering event, event parameters, repeat rules, frequency limits.
* **What** — the **DeepLink** URL to open.

For the URL to do anything useful, your app needs a deep-link handler registered via the SDK. See [Android integration](/developer-guide/android-integration) and [iOS integration](/developer-guide/ios-integration).

### Blocking

Marked **Soon** in the dashboard. Not available yet.

## The When step — repeat rules and frequency limits

Every campaign is triggered by an event. The When step lets you control how often a single user is eligible to see it, beyond the audience filter.

**Triggering Event.** Pick an event name — system events like `SessionStarted` or any custom event your app sends. Optionally filter by **event parameters** ("only when `screen_name` equals `paywall`").

**Repeat Rules.** How often the campaign is allowed to fire for one user:

* **every** N events — "every 3rd purchase".
* **on** the Nth event — "on the 1st purchase only".

Scope: **globally** (across sessions, across time) or **in session** (this session only).

**Frequency Limits.** Overall caps, independent of the repeat rules:

* Per **session** — at most N times in a single session.
* Per **device** — at most N times per device, optionally over a window (seconds, minutes, hours, or days).

A campaign that fires too often is almost always a repeat-rules or limits problem, not a targeting one.

## Choosing a type — quick table

| Goal                                               | Type                       |
| -------------------------------------------------- | -------------------------- |
| Ask users to rate the app                          | Rate & Review              |
| Open a paywall variant in-app                      | Deep Link (custom scheme)  |
| Send users to a web offer or survey                | Deep Link (`https://`)     |
| Route marketing cohorts into different onboardings | Deep Link (custom scheme)  |
| Block a flow until something happens               | (Blocking, when available) |

## Related

* [Scenarios and campaigns (concepts)](/concepts/scenarios-and-campaigns) — what a campaign is and how it fits a scenario
* [Creating a campaign](/user-guide/creating-a-campaign) — step-by-step walk-through
* [Targeting an audience](/user-guide/targeting-an-audience) — the Who step in depth
* [Sessions and events](/user-guide/sessions-and-events) — events the When step can listen for
* [Campaign delivery](/concepts/campaign-delivery) — how a campaign is evaluated


# Targeting an audience

How to narrow a campaign from "everyone" down to the right slice of users, using the rule builder in the campaign form's **Who** step.

> Dashboard walkthrough. For the targeting model and the attributes you can target, see [Targeting and audiences](/concepts/targeting-and-audiences).

**Use this when** a campaign should only apply to some users — a country, an app version, users who installed recently, users who did (or never did) something in your app, or users who match a property you control. **Don't use this when** you want to control *when* a campaign fires (that's the **When** step and the triggering event) or *what* it shows (that's the **What** step).

## Where to find it

Audience rules live in the **Who** step of the campaign form:

1. Open a project → **Campaigns** → **New campaign** (or open an existing draft).
2. Fill in the name and type in step one.
3. On the **Who** step, start from **All users** and click **Add rule**.

Every rule you add narrows the audience. Multiple rules combine with **And** — a user must match all of them. There is no "or" between rules; if you need an either/or audience, make two separate campaigns.

## Rule types and operators

Each rule lives in its own card. Add, edit, or remove rules in any order.

| Rule                        | Operators                                                                              | Example                                           |
| --------------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------- |
| **Countries**               | include / exclude one or more countries                                                | Include **Germany, France, Spain**                |
| **OS Version**              | `=`, `!=`, `>`, `<`, `>=`, `<=`                                                        | `>=` `17` (iOS 17 and up). Add twice for a range. |
| **App Version**             | same as OS Version                                                                     | `>=` `4.2.0`                                      |
| **App Install Version**     | same as OS Version                                                                     | `>=` `3.0.0` and `<` `4.0.0` (twice)              |
| **Applications**            | include / exclude one or more applications in the project                              | Include the iOS build only                        |
| **Custom Property**         | depends on the value type — see below                                                  | `tier` `=` `premium`                              |
| **Install Date — absolute** | is, is before, is after                                                                | "is after 2026-01-01"                             |
| **Install Date — relative** | is less than N days, is more than N days                                               | "is less than 7 days"                             |
| **Event**                   | count of times done, first/last occurrence date, optional property filters — see below | `Purchase` at least **3** times                   |

Custom Property operators by value type:

| Type     | Operators                                           |
| -------- | --------------------------------------------------- |
| String   | `=`, `!=`, is set, is not set                       |
| Number   | `=`, `!=`, `>`, `<`, `>=`, `<=`, is set, is not set |
| Boolean  | `=`, `!=`, is set, is not set                       |
| DateTime | is, is before, is after, is set, is not set         |

**Is set** and **is not set** check whether the property exists on the user at all — they don't need a value. Property keys are suggested from what the SDK has reported for this project; pick from the dropdown to avoid typos.

## Adding an Event condition

Target users by what they have done in your app — how often, and when.

{% hint style="info" %}
Event conditions require apps built with SDK 0.6.1 or later. On older app builds, a campaign that uses an Event condition never matches — it fails safely and silently.
{% endhint %}

![Event conditions in the Who step: counts, occurrence dates, and property filters](/files/8iMAizsJDkkEM6tNubkz)

1. On the **Who** step, click **Add rule** and choose **Event**.
2. Pick the event — for example `Purchase` or `WorkoutCompleted`.
3. Choose what to check:
   * **How many times** — exactly / not exactly / more than / fewer than / at least / at most **N** times, or simply **has happened (ever)** / **has never happened**. Counts cover the lifetime of the install.
   * **First occurrence** or **Last occurrence** — relative ("more than **14** days ago", with an "…or never" variant that also matches users who never did it; "within the last **7** days") or absolute ("before" / "after" a date). Absolute dates cover whole calendar days (UTC); relative days are rolling 24-hour windows, not calendar days.
4. Optionally add **property filters** — type a key and value, compared with equals or not equals (number values also support greater / less). `Purchase` with `product` equals `pro` counts only pro purchases: occurrences that don't match the filters are ignored for both the count and the first/last dates.

Like every other rule, Event conditions combine with **And**, are evaluated live on the device, and work offline. Event history starts when the app is installed. A campaign can have up to 20 Event conditions.

## Worked examples

### Paying users on iOS 17+ in Germany

1. **Custom Property** — `subscription_status` `=` `active`
2. **Countries** — include **Germany**
3. **OS Version** — `>=` `17` (and consider filtering by application for iOS-only)

### New users on the latest app version

1. **Install Date** — is less than **14** days ago
2. **App Version** — `>=` `5.0.0`

### Everyone except early adopters on old builds

1. **App Install Version** — `>=` `4.0.0`
2. **App Version** — `>=` `5.0.0`

### Power users with a specific flag

1. **Custom Property** — `lifetime_purchases` `>=` `5`
2. **Custom Property** — `beta_opt_in` is set

### Win back lapsed workout loggers

1. **Event** — last `WorkoutCompleted` occurrence more than **14** days ago
2. **Install Date** — is more than **30** days ago (so brand-new users don't get a win-back message)

### Repeat pro buyers

1. **Event** — `Purchase` at least **3** times, with property filter `product` `=` `pro`

## Tips

* **Start broader than you think.** A too-narrow audience that matches almost nobody looks the same as a broken campaign. Widen, confirm it fires, then tighten.
* **Name custom properties consistently.** Amply's convention is `snake_case` for property keys (and `PascalCase` for event names). The dropdown shows exactly what the SDK reported — match whatever your developer set up.
* **If a property isn't in the dropdown,** the SDK hasn't sent it yet from any device in this project. Trigger it on a test device first.
* **Country rules use device region, not IP.** A user travelling abroad still counts as their device's region.
* **Install date reflects the first install Amply saw.** Uninstall and reinstall resets it.

## Related

* [Targeting and audiences (concepts)](/concepts/targeting-and-audiences) — the model behind rules and attributes
* [Creating a campaign](/user-guide/creating-a-campaign) — full walk-through of the campaign form
* [Sessions and events](/user-guide/sessions-and-events) — confirm your custom property is arriving
* [User attributes](/developer-guide/user-attributes) — how developers set custom properties from the SDK


# Managing deeplinks

A deeplink campaign sends a user to a specific place in your app at a moment you choose. You pick who sees it, what triggers it, and which URL the app receives. Your engineering team decides what that URL opens.

**Use this when** you want to steer users to a specific screen, paywall, or flow in response to in-app behavior — without shipping a new app version. **Don't use this when** you only need to show a message. A native rate-and-review prompt or a custom popup is a better fit for that.

## Before you start

* The mobile team must register a deeplink listener in the app once. See [Handling deeplinks](/developer-guide/handling-deeplinks).
* Agree on a URL convention across product and engineering (for example, `yourapp://paywall/annual`). The dashboard stores whatever you type; the app decides how to parse it.
* Have at least one event available in the project that represents the moment you want to react to. Custom events come from `Amply.track(...)` calls in the app; system events (like `SessionStarted`) are available by default.

## Create a deeplink campaign

1. Open **Campaigns** in the project sidebar and click **New campaign**.
2. Enter a **Campaign name** the team will recognize. Choose type **Deep Link**.
3. **Who** — pick the audience for the campaign. You can combine:
   * **Countries** (include or exclude)
   * **OS Version** (compare against a value)
   * **App Version** / **App Install Version**
   * **Applications** (if the project has more than one)
   * **Custom Property** (any user attribute you set from the SDK)
   * **Install Date** (absolute or relative) Leave the section empty if you want to target everyone in the project.
4. **When** — choose the **Triggering event**. Add event parameter filters if you want only a subset of fires (for example, `source == "home"`). Configure **Repeat Rules** (every N events, or at specific session counts) and **Frequency Limits** if you want to cap how often a single user sees the campaign.
5. **What** — enter the **DeepLink** URL that should be passed to the app. Example values:
   * `yourapp://paywall`
   * `yourapp://feature-discovery?variant=v2`
   * `yourapp://onboarding/step3`
6. Save. The campaign is created in **Draft** state.

## When a deeplink fires

Amply evaluates campaigns every time an event is tracked. For each campaign:

1. The **Who** conditions are checked against the current user.
2. The **When** conditions are checked against the event being tracked.
3. If both match, the campaign is eligible to fire.
4. Frequency limits are applied. If the user is under the cap, the deeplink URL is sent to the mobile SDK.
5. The SDK calls the deeplink listener your engineers registered. They decide what screen to open.

Only campaigns in **Active** state are evaluated. **Draft** and **Cancel** campaigns are ignored.

## Activate, pause, or cancel

Campaign state is changed from the campaign list or the campaign detail page.

* **Draft → Active** — starts delivery. No app release required.
* **Active → Cancel** — stops delivery immediately. No app release required.
* Cancelled campaigns can be returned to Active or duplicated into a new draft.

Changes propagate to installed apps on the next campaign sync. For most flows this is the next session start.

{% hint style="info" %}
You can change the **What** URL on an Active campaign. Every session started after the change receives the new URL. Existing sessions keep the URL they already fetched.
{% endhint %}

## Test a deeplink from a staging build

The goal is to verify that (a) your campaign is eligible for your account, and (b) the app handles the URL correctly once received.

1. Install a staging or development build of the app that is wired to the same Amply project as the campaign. Your mobile team sets this up during integration.
2. Make sure your device matches the campaign's **Who** conditions. If you narrowed by country or a custom property, either widen the conditions for the test or set the matching property from the app.
3. Fire the **Triggering event** from the app. For a custom trigger like `PaywallShown`, you trigger it by doing whatever action the code hooks into.
4. Watch the deeplink listener in the app's logs. The URL you typed in **What** should arrive with the campaign metadata.
5. Confirm the app opens the expected screen.

If nothing fires, walk through the checklist in [testing-and-rollout.md](/user-guide/testing-and-rollout#troubleshooting).

## Worked example — onboarding feature discovery

Goal: show a feature-discovery screen to users on their third session who have not used the feature yet.

* **Who**: Custom Property `used_feature_x` is not set (or equals `false`).
* **When**: event `SessionStarted`, repeat every session, frequency limit "1 time per device".
* **What**: `yourapp://feature-discovery?feature=export-pdf`.

Your app's deeplink handler reads `feature=export-pdf` and opens the corresponding screen with a tooltip.

## Common patterns

| Pattern                          | Trigger                                        | URL shape                                 |
| -------------------------------- | ---------------------------------------------- | ----------------------------------------- |
| Paywall on deep session          | `SessionStarted`, every 3rd session            | `yourapp://paywall/annual`                |
| Winback after gap                | `SessionStarted`, audience filter on last-seen | `yourapp://welcome-back?offer=50`         |
| Feature wall                     | `FeatureClick` with `feature == "pdf_export"`  | `yourapp://paywall/feature?id=pdf_export` |
| Invite prompt at positive moment | `PositiveMoment` event                         | `yourapp://invite-friends`                |

## Related

* [Handling deeplinks](/developer-guide/handling-deeplinks) — the developer side: registering a listener and routing the URL.
* [Campaign actions](/concepts/campaign-actions) — when to pick one action type over another.
* [Testing and rollout](/user-guide/testing-and-rollout) — how to validate a campaign before it goes to all users.
* [Campaigns](/user-guide/campaigns) — full reference on campaign structure.


# Rate & review flows

The native app-store review prompt is limited and unforgiving: each store caps how often it can appear, and a badly-timed prompt can surface a frustrated user and a one-star review. This page covers how to gate that prompt behind a positive moment and an audience filter so the users who see it are the ones most likely to leave a five-star rating.

**Use this when** you want more positive ratings and fewer negative ones — and you have an in-app moment that reliably signals the user is happy (a completed task, a milestone, a successful purchase). **Don't use this when** you have no way to detect a positive moment. Asking at session start or after a generic screen view hurts your rating more than it helps.

## The idea

A naive Rate & Review campaign runs on every session and prompts anyone the store allows. You get ratings from whoever happens to be around — including users mid-frustration.

A gated Rate & Review campaign only fires after a signal that the user is in a good state. That signal is just an event your app already tracks. You use it as the trigger, and you narrow the audience further with targeting rules. The result is fewer prompts, and the prompts that do fire land on happier users.

The guiding principle: a store rating is expensive. Both Apple and Google limit how often the native sheet can appear, so every call is effectively a finite resource. Spend it on users who have already shown positive engagement. Never spend it on a user the moment they open the app for the first time.

A common extension is to run a **sentiment check** in front of the native prompt: a custom in-app question that asks the user if they are enjoying the app. Only users who answer positively are eligible for the Rate & Review campaign. Negative responders are routed to an in-app feedback form instead of the public store listing. This keeps low-star feedback inside the product where it can be acted on, rather than on the store page where it hurts acquisition. See [custom-popups.md](/user-guide/custom-popups) for the front-end of that flow.

## Pick the positive-moment event

Before touching the dashboard, decide what counts as a positive moment in your product. Good candidates:

* A task the user came to the app to complete (an export, a transaction, a save).
* A streak or milestone (seventh consecutive day, tenth item added).
* A positive response to an in-app sentiment question.

Bad candidates:

* App launch / session start.
* A screen view with no action.
* Any event that also fires during errors or failed flows.

The event must be tracked by the mobile SDK. If it isn't yet, ask engineering to add one `Amply.track(...)` call at the right spot. Name it something recognizable, for example `PositiveMoment`, `TaskCompleted`, or `PositiveRatingShared`.

## Worked example — positive-moment trigger

A fitness app tracks `WorkoutCompleted` whenever a user finishes a session. The team decides any user who has completed at least three workouts in the app's history is a strong candidate for a five-star rating.

* **Triggering event**: `WorkoutCompleted`.
* **Repeat Rules**: on the third occurrence globally (not session-scoped).
* **Frequency Limits**: one prompt per device, lifetime.
* **Who**: app version is current, country is a launch market.

Result: users who hit the workout milestone see the store prompt once, ever. Everyone else is invisible to the campaign.

## Create the campaign

1. Open **Campaigns** in the project sidebar and click **New campaign**.
2. Enter a name. Choose type **Rate & Review**.
3. **Who** — narrow the audience so the prompt lands on engaged users only. Typical conditions:
   * **App Version** — exclude users on older builds that have known bugs.
   * **Custom Property** (for example `subscription_status == "active"`) — only prompt users who have completed onboarding or paid.
   * **Install Date** — exclude brand-new installs (the store caps repeat prompts, so spending a prompt on a first-day user is risky).
4. **When** — set the **Triggering event** to the positive-moment event you picked.
   * Under **Repeat Rules**, set the campaign to fire only after the event has occurred enough times to be meaningful (for example, on the third `TaskCompleted`, not the first).
   * Under **Frequency Limits**, cap the campaign to one prompt per device across a long window. The store will not show the native sheet more often than its own policy allows, so burning attempts is wasteful.
5. **What** — Rate & Review campaigns do not need a deeplink URL. The SDK calls the native review API on both platforms.
6. Save as **Draft**, verify (see [testing-and-rollout.md](/user-guide/testing-and-rollout)), then move to **Active**.

## How delivery behaves on each platform

The campaign asks the platform to show its native review sheet. The platform decides whether to actually display it.

| Platform | Behavior                                                                                                                                                                                           |
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| iOS      | Triggers the native iOS rate prompt (handled by the SDK). iOS throttles prompts per user per app, so not every call results in a visible sheet.                                                    |
| Android  | Calls the Play Store in-app review API. In Play Store-distributed release builds the sheet may appear. In debug or sideloaded builds the SDK shows a simulated dialog that does not post a review. |

Because the store limits visibility, never design a flow that assumes the prompt definitely appeared. Treat it as best-effort.

## Measuring success

Keep an eye on:

* The count of campaign triggers in the campaign detail view. This counts matched evaluations, not confirmed native sheet displays — the store decides whether the sheet actually appears.
* Your store rating over time (App Store Connect and Play Console both show weekly averages).
* The share of low-star reviews. If a negative-feedback side-flow is in place, low-star store reviews should trend down.
* The distribution of positive-moment event counts per user. If most users never reach the trigger, the event is too rare and the campaign will never fire for them. If every user hits it in the first session, the event is too cheap and the prompt will be wasted on people who haven't seen enough value yet.

## Variations

* **Three-step flow** — custom sentiment popup, then Rate & Review for positives, in-app feedback deeplink for negatives. Most powerful; requires a custom popup and a feedback form in the app. See [custom-popups.md](/user-guide/custom-popups) for the popup side and [managing-deeplinks.md](/user-guide/managing-deeplinks) for the feedback deeplink.
* **Single-event flow** — pick one strong positive event and gate directly on it. Minimal setup, good default. Start here if you don't yet have a sentiment popup built.
* **Audience-only flow** — for apps without a clear positive moment, gate by `session.counter >= N` and a retention-indicating custom property. Weaker signal, still better than untargeted.

## Common pitfalls

* **Prompting too early.** Firing on session 1 or session 2 burns the store's allowance on users who haven't experienced the product yet. Wait for a retention signal.
* **Prompting everyone who crosses the event.** The event alone is not enough — combine it with an audience filter so churned or dormant users are excluded.
* **Forgetting frequency limits.** Even a well-chosen event will fire repeatedly for power users. Cap to one prompt per long window per device.
* **Testing on a debug build and assuming production works.** The Android Play Store in-app review API only works in Play-distributed release builds. A positive test on a debug build only tells you the campaign evaluated; it does not confirm a live sheet will appear.

## Maintenance

Rate & Review campaigns are not set-and-forget. Review them quarterly:

* Confirm the positive-moment event is still a strong signal. Products evolve; the event that correlated with happiness last year may not this year.
* Check whether any low-star reviews are still citing issues that should have been caught by the audience filter (for example, users on an unsupported OS version). Tighten the audience if so.
* If your app now has an in-app feedback form, upgrade the flow to the three-step variation so negatives never reach the store.

## Related

* [Custom popups](/user-guide/custom-popups) — build the sentiment check that feeds this flow.
* [Managing deeplinks](/user-guide/managing-deeplinks) — route low-rating users to an in-app feedback form.
* [Campaign actions](/concepts/campaign-actions) — how the action types fit together.
* [Campaigns](/user-guide/campaigns) — campaign structure reference.


# Custom popup flows

Amply doesn't have a native Custom Popup campaign type today — this page documents the shipped pattern: fire a DeepLink campaign to an in-app popup screen your app renders.

A custom popup is an in-app message you control — the title, the body, the buttons, and what happens on tap. Today you create custom popups by pairing a **Deep Link** campaign with a screen your app already knows how to render. The dashboard decides when to show it and to whom; the app decides how it looks.

**Use this when** you need to communicate something in your own visual language (sentiment checks, feedback prompts, in-app offers, soft push pre-prompts) at a specific moment in the user's session. **Don't use this when** you only need to route the user to an existing screen — a plain [deeplink campaign](/user-guide/managing-deeplinks) is simpler. Also don't use this for the native review sheet — use a [Rate & Review campaign](/user-guide/rate-review-flows) instead.

## Custom popup vs deeplink — quick rule

* If the outcome is "take the user to an existing screen," use a deeplink campaign with no popup.
* If the outcome is "show a message or ask a question inside the app," use a custom popup (delivered as a deeplink to a dedicated popup screen).
* If the outcome is "show the native store review sheet," use a Rate & Review campaign.

## How it works today

Your mobile team implements one reusable popup screen. That screen reads parameters from the deeplink URL — title, body, primary CTA label, secondary CTA label, variant — and renders them using your design system. You then create a Deep Link campaign whose URL points at that popup screen.

Result: the dashboard controls *when* the popup shows and *what text* it shows. The app controls how it looks and what the buttons do. Content changes do not require a release.

{% hint style="info" %}
A native "Custom Popup" campaign type is planned. Until it ships, the deeplink-to-popup-screen pattern described below is the supported approach.
{% endhint %}

## Before you start

Engineering needs to have built the popup screen once. Agree on:

* The URL shape. Example: `yourapp://popup?id=sentiment_check&title=Enjoying%20the%20app?&primary=Yes&secondary=Not%20really`.
* Which parameters are supported (title, body, primary/secondary CTA, icon, variant).
* What each CTA does. Common patterns: dismiss, track an event, fire a second deeplink, open system settings.

Document the contract somewhere the marketing team can reference without reading code. The developer-side docs for this live at [Showing custom popups](/developer-guide/showing-custom-popups).

## Create a popup campaign

1. Open **Campaigns** → **New campaign**. Enter a name. Choose type **Deep Link**.
2. **Who** — define the audience. Standard targeting applies: countries, app version, install date, custom properties.
3. **When** — pick the **Triggering event** and configure repeat rules and frequency limits. Custom popups are fatiguing if overused. A one-per-device limit on a 30-day window is a safe default for non-time-critical prompts.
4. **What** — build the **DeepLink** URL that encodes the popup content. For the example sentiment check:

   ```
   yourapp://popup?id=sentiment_check&title=Enjoying+the+app%3F&primary=Yes&secondary=Not+really
   ```

   Parameter values must be URL-encoded. Test long strings carefully.
5. Save as **Draft**. Verify in a staging build. Promote to **Active**.

## Content fields

The exact fields depend on what your app's popup screen supports. A typical contract:

| Field       | Purpose                                  | Notes                                                            |
| ----------- | ---------------------------------------- | ---------------------------------------------------------------- |
| `id`        | Unique identifier for the popup template | Lets the app apply variant-specific layout or tracking.          |
| `title`     | Bold top line                            | Keep under 40 characters — it wraps awkwardly on small screens.  |
| `body`      | Supporting sentence                      | Optional. Keep under 120 characters.                             |
| `primary`   | Main button label                        | Should match the intended action, not say "OK".                  |
| `secondary` | Secondary button label                   | Optional. Use for a soft dismiss ("Not now") or a counter-offer. |
| `icon`      | Icon key the app recognizes              | Optional.                                                        |
| `variant`   | Style key (info, warning, celebration)   | Optional.                                                        |

## Display rules

Every rule from the [Campaigns](/user-guide/campaigns) page applies. The ones that matter most for popups:

* **Repeat Rules** — show after the Nth fire of the event, not the first.
* **Frequency Limits** — cap per device over a time window. Popups that appear more than once a week feel broken.
* **Audience filters** — exclude users who already completed the goal (for example, exclude users whose custom property `invited_friends` is true from the invite popup).

## When a popup is the wrong tool

* If you need to block the user from using a feature until they act, that belongs in app-side logic, not a popup campaign.
* If you need a multi-step form, deeplink into a full screen, not a popup.
* If the message is time-critical and users must act within hours, a push notification is a better channel.

## Worked example — soft push pre-prompt

Goal: ask users whether they want notifications inside your UI before triggering the system permission dialog. If they say no, never call the system API (the system prompt can only be shown once).

* **Who**: users who have not yet been asked (custom property `push_asked` is not set).
* **When**: event `OnboardingFinished`, repeat every event, one-per-device frequency limit.
* **What**: `yourapp://popup?id=soft_push&title=Get+updates%3F&primary=Yes&secondary=Maybe+later`.

In the app, the popup screen reads `id=soft_push`. "Yes" calls the system push API; "Maybe later" sets `push_asked=true` and closes the popup.

## Worked example — feature feedback

Goal: collect a one-tap reaction after a user has interacted with a new feature twice.

* **Who**: users with custom property `feature_x_uses >= 2` and `feedback_given` not set.
* **When**: event `FeatureXClosed`.
* **What**: `yourapp://popup?id=feature_x_feedback&title=How+was+it%3F&primary=%F0%9F%94%A5&secondary=Meh`.

Tap handlers track the answer as an event and set `feedback_given=true`.

## Related

* [Managing deeplinks](/user-guide/managing-deeplinks) — because popups are delivered as deeplinks.
* [Campaign actions](/concepts/campaign-actions) — the concept page covering campaign action types.
* [Showing custom popups](/developer-guide/showing-custom-popups) — engineer-side integration.
* [Rate & Review flows](/user-guide/rate-review-flows) — combine a sentiment popup with a gated review prompt.


# Sessions and events

The Events screen is where you confirm the events your campaigns rely on are actually arriving — and where you turn individual events on or off for campaign use.

> Dashboard task page. For the model of sessions and events, see [Sessions and events](/concepts/sessions-and-events).

**Use this when** you're setting up a campaign and want to verify its triggering event is being reported, or you're auditing what the SDK knows about. **Don't use this when** you need a per-user analytics report — Amply isn't a BI tool.

## What the Events screen shows

In a project, open **Events**. You'll see one row per event name, each with:

* **Name** — the exact event name as reported by the SDK.
* **Last Seen** — when the event was last seen on each platform. iOS and Android are listed separately, with the SDK's most recent timestamp per platform. Blank if a platform has never reported it.
* **Available** — a toggle. When off, the event is hidden from campaign configuration dropdowns.

The list covers every event name the SDK has ever reported for this project, across all users and devices. New names appear as soon as the first device reports them.

## Using events in a campaign

The most common flow:

1. Open **Events** and find the name you want to trigger on.
2. Confirm **Last Seen** shows a recent timestamp on the platforms you care about — that's proof the event is actually arriving.
3. Go to **Campaigns** → **New campaign**, and in the **When** step, pick that event from the **Trigger when event** dropdown.
4. Optionally add parameter filters ("only when `tier` equals `premium`").

If an event isn't in the dropdown:

* It might be toggled off — flip **Available** back on.
* It might not have been reported yet — ask an engineer to trigger it on a test device, then refresh.
* The name might be different from what you expect — copy-paste from the Events screen rather than typing.

## Turning events on and off

The **Available** toggle on each row controls whether the event is offered in campaign configuration dropdowns (the Triggering Event picker and custom-property pickers elsewhere in the form).

* Turn off noisy or low-signal events you don't want listed in the dropdowns.
* Turn on an event you had previously disabled.

Toggling **Available** does not stop the SDK from sending the event — it only affects what the dashboard offers for configuration.

## A note on custom events

Custom events are event names your app sends from code. As a PM your role is usually:

1. Decide what events you need (names and properties) for a campaign.
2. Hand the list to your engineer.
3. Once they've shipped, watch the Events screen to confirm the names show up.
4. Use them in campaigns.

For the standard events the SDK reports automatically, the naming conventions, and how custom events get tracked from code, see [Sessions and events](/concepts/sessions-and-events) and [Tracking events](/developer-guide/tracking-events).

## Related

* [Sessions and events (concepts)](/concepts/sessions-and-events) — the model: sessions, the standard event catalog, naming conventions
* [Creating a campaign](/user-guide/creating-a-campaign) — picking a triggering event in the When step
* [Targeting an audience](/user-guide/targeting-an-audience) — using event-sourced properties as audience rules
* [Tracking events](/developer-guide/tracking-events) — how engineers send custom events from the SDK


# Testing and rollout

Every campaign change goes live the moment you save it and move the campaign to **Active**. There is no app release, no review window, and no store approval. That release-free control is what Amply's orchestration is for — and it's also why you need a testing routine. This page covers how to validate a campaign in staging, how to stage the rollout yourself using audience targeting, and how to pull a campaign instantly if something is wrong.

**Use this when** you are about to publish a campaign to real users, or a live campaign is misbehaving. **Don't use this when** you are still iterating on the rule design and haven't yet decided what to validate.

{% hint style="info" %}
Amply has no staging/QA state — the safest test is an Active campaign with an audience of one. Draft campaigns are not evaluated for live traffic.
{% endhint %}

## Why "no release required" matters

Changing in-app behavior normally means: code change → internal QA → store submission → store review (often days) → phased store rollout → users finally see the change. Amply skips all of that for anything you express as a campaign.

* **Change a trigger**: edit the **When** step, save. Users who start a session after the change use the new rules.
* **Change the audience**: edit the **Who** step, save.
* **Change the deeplink URL**: edit the **What** step, save.
* **Pull the campaign**: change state from **Active** to **Cancel**. New evaluations stop immediately.

The app code — the deeplink handler, the popup screen, the SDK — does not change. Only the rules the dashboard evaluates change.

## Phase 1 — Validate in staging before publishing

Only **Active** campaigns are evaluated. To test without exposing users, narrow the **Who** rules so the campaign matches only your test device, then move it to **Active**. Keep the narrow rules until you have verified behavior, then widen.

### Step 1: wire the staging build to the same project

Your mobile team runs a staging build that points at the same Amply project used for production. If they run a separate project for staging, make the test campaign in that project instead — and remember to re-create it in production after validation.

### Step 2: make yourself match the audience

The cleanest way to test: temporarily narrow the campaign to an audience that is only you.

* Set a **Custom Property** condition like `qa_tester == "your-name"` and set that property from the staging app.
* Or narrow by **Country**, **OS Version**, or **App Version** to a combination only your test device matches.

If the campaign will eventually target everyone, keep the narrow version Active until you have verified behavior, then widen the audience.

### Step 3: fire the trigger and confirm delivery

1. Ensure the campaign is **Active** while you test (Draft campaigns are skipped).
2. Fire the **Triggering event** by doing whatever action calls `Amply.track(...)` in the app, or by restarting the app if the trigger is `SessionStarted`.
3. Watch the deeplink listener (for Deep Link campaigns) or the native review sheet (for Rate & Review).
4. If nothing happens, check the campaign detail page for the trigger count — it increments when a match is recorded, regardless of whether the action visibly succeeded.

### Step 4: widen the audience and move to production use

Once behavior is correct:

1. Edit the **Who** step to the real production audience.
2. Save. The campaign is now targeting real users but still in Active state on your test device, so double-check once more.

### Troubleshooting

| Symptom                                                         | Likely cause                                                                                 |
| --------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| No trigger count increments                                     | Campaign is in **Draft**, or the triggering event name has a typo (they are case-sensitive). |
| Trigger count increments but nothing visible happens in the app | Deeplink handler not registered, or the URL scheme doesn't match what the app expects.       |
| Some users trigger but not others                               | Audience conditions exclude them. Check country, app version, and custom property filters.   |
| Campaign fires too often                                        | **Frequency Limits** not set or set too permissively.                                        |
| Campaign does not fire again for the same user                  | Frequency limit was hit. Increase the window or raise the cap.                               |

## Phase 2 — Control who sees it (phased rollout)

Amply does not ship a percentage-based rollout slider today. You get the same effect by widening the audience in steps.

1. **Start narrow.** When moving from Draft to Active, keep the **Who** targeting tight: one country, latest app version, maybe a specific custom property.
2. **Wait, then measure.** Check the campaign detail view for trigger counts and any downstream events (for example, post-popup `feedback_given`). Cross-reference with product analytics for any regression on the core KPIs.
3. **Widen.** Edit the **Who** step and remove one constraint (add more countries, drop the app-version filter). Save.
4. **Repeat** until the audience is what you want it to be.

Because edits to an Active campaign take effect within minutes, each widening step lands in minutes, not days.

## Instant rollback

If a live campaign is hurting users — wrong screen, broken deeplink, prompt firing too often — stop it immediately:

1. Open the campaign.
2. Change state from **Active** to **Cancel** (or use the state control on the list view).

From that moment on, the campaign stops matching and no new deliveries happen. Work that was already dispatched to an in-flight session may still complete, but nothing new starts.

A cancelled campaign stays in the list. You can fix the issue, then either:

* Return it to **Active** (same campaign, same ID, keeps historical counts), or
* Duplicate it into a new **Draft**, edit, validate, activate (use this if the fix is significant).

## Checklist before you move a campaign to Active

* [ ] The campaign ran end-to-end on a staging or real test device.
* [ ] The audience condition that narrowed it to you has been removed or broadened.
* [ ] **Frequency Limits** are set. At least one cap per device per time window.
* [ ] The deeplink URL or review-prompt behavior is what you expect.
* [ ] Someone else on the team knows the campaign is going live.

## Related

* [Managing deeplinks](/user-guide/managing-deeplinks) — testing deeplink campaigns specifically.
* [Rate & Review flows](/user-guide/rate-review-flows) — testing native review prompts.
* [Custom popups](/user-guide/custom-popups) — testing popup content.
* [Testing your integration](/developer-guide/testing-your-integration) — the engineer-side checklist.


# Entry-based onboarding routing

Route users into different onboarding flows based on the Custom Product Page or ad creative that brought them in — no release required.

**Use this when** you run multiple CPPs or ad creatives and each one promises a different first-run experience. **Don't use this when** every acquisition source leads to the same product promise; routing adds noise without payoff.

## Goal

Ads sell one thing, the app opens into another. If CPP A promises "meal planning in 30 seconds" and CPP B promises "calorie tracking for athletes," the generic onboarding flow breaks the promise for both. This recipe splits onboarding by entry point so each cohort sees the flow that matches the creative they clicked.

## Setup

### In the dashboard

* Create one campaign per entry point. Name them after the CPP or creative ID: `onboarding-cpp-meal-planner`, `onboarding-cpp-athlete`, `onboarding-default`.
* Trigger: session start, first session only.
* In the conditions editor, gate each campaign on an attribute that identifies the source (for example, `acquisition_source = "cpp_meal_planner"`).
* Action: show the matching onboarding deeplink (`yourapp://onboarding/meal-planner`, etc.).
* Make `onboarding-default` the fallback with the broadest audience — no source condition at all — so organic installs (with no `acquisition_source` value) match it while the narrower, source-gated campaigns match their own cohorts.
* Publish all three at once so first-session users always match exactly one.

### In the app (engineering hand-off)

* On install, read the entry-point hint from the attribution SDK or the CPP query string and write it as a user attribute:

  ```swift
  amply.setCustomProperty(key: "acquisition_source", value: "cpp_meal_planner")
  ```
* Register a deeplink handler that maps `yourapp://onboarding/<flow>` to the corresponding in-app flow controller.
* Call `setCustomProperty` before the first session starts so the first campaign evaluation sees the right value.

## How it runs

1. User taps an ad, lands on CPP A, installs the app.
2. App boots, SDK initializes, attribution library resolves the source, app writes `acquisition_source = "cpp_meal_planner"`.
3. Session starts. Amply evaluates candidate campaigns. The narrower `acquisition_source` condition on `onboarding-cpp-meal-planner` matches this user; the broader `onboarding-default` would also match but is selected only when no narrower campaign does.
4. Amply fires the deeplink `yourapp://onboarding/meal-planner`. The app routes into the meal-planner flow.
5. User finishes onboarding. Subsequent sessions evaluate again — the campaign is first-session-only, so it does not fire again.
6. A user who installs organically matches no source and falls through to `onboarding-default`.

## Metrics to watch

* Onboarding completion rate, split by `acquisition_source`. Expect the segmented flows to beat the generic one by 10-25%.
* Trial-start rate in the first 24 hours, again split by source.
* Drop-off step within each flow. If a segmented flow is worse than the default, the creative and the flow are misaligned.
* Deeplink resolve success rate. A sudden drop means the app is not handling one of the new URLs.

## Related

* [Creating a campaign](/user-guide/creating-a-campaign) — building the campaigns used here
* [Targeting an audience](/user-guide/targeting-an-audience) — how the source condition is expressed
* [Managing deeplinks](/user-guide/managing-deeplinks) — registering the onboarding routes
* [User attributes](/concepts/user-attributes) — why `acquisition_source` belongs on the user, not the event
* [Handling deeplinks](/developer-guide/handling-deeplinks) — the deeplink listener on the SDK side
* [AI-assisted integration](/getting-started/ai-assisted-integration) — describe this campaign in plain language and have your AI assistant build it


# Paywall versioning

Swap paywall variants, target them to specific cohorts, and roll back the same day — no App Store or Play Store submission.

**Use this when** you want to test seasonal pricing, headline copy, or a new paywall layout without blocking on a release train. **Don't use this when** the variant needs new native UI that is not already shipped in the build; remote config cannot introduce new screens, only pick between ones that exist.

## Goal

Paywall changes that live in app code are expensive to iterate on: two weeks of review, a rollback means another submission, and you cannot target a variant to a specific cohort. This recipe ships the paywall as a remote-selectable deeplink so the dashboard decides which variant a given user sees and when.

## Setup

### In the dashboard

* Create one campaign per variant: `paywall-control`, `paywall-winter-offer`, `paywall-long-copy`.
* Trigger: session start, or on a custom event like `PaywallRequested` fired from a feature gate.
* Target audience: the free cohort (`subscription_status != "pro"`).
* Split variants by audience filter rather than a percentage split — for example, send `paywall-winter-offer` to users with `country` in `["US","CA"]`, `paywall-long-copy` to users with `install_date` after a given date, and leave `paywall-control` as the broadest-audience fallback that catches every free user not matched by a narrower variant.
* Action: fire the deeplink for that variant (`yourapp://paywall/control`, `yourapp://paywall/winter`, `yourapp://paywall/long-copy`).
* To retire a seasonal variant, move its campaign state to `Cancel` on the planned end date. The change propagates on the next session. Use `Draft` state to stage a variant that is not yet live.

### In the app (engineering hand-off)

* Ship every variant screen in the build before you promote the campaign. Remote config selects between them; it cannot create them.
* Register a deeplink handler for `yourapp://paywall/<variant>` that resolves `<variant>` to the matching view controller.
* Track the outcome so variants are comparable:

  ```typescript
  await Amply.track({ name: 'PaywallViewed', properties: { variant: 'winter' }});
  await Amply.track({ name: 'PurchaseCompleted', properties: { variant: 'winter', product: sku }});
  ```

## How it runs

1. User opens the app. Session starts.
2. Amply evaluates the paywall campaigns. The user's `country` is `"US"`, so the narrower `paywall-winter-offer` audience matches; the broader `paywall-control` audience would also match but is only selected when no narrower variant does.
3. Amply fires `yourapp://paywall/winter`. The app presents the winter variant.
4. App tracks `PaywallViewed` with `variant: "winter"`. If the user converts, `PurchaseCompleted` carries the same variant tag.
5. The growth team compares conversion across `variant` values in their analytics tool.
6. When the winter window closes, the operator moves that campaign to `Cancel`. Amply stops firing it. Users fall back to the control variant.

## Metrics to watch

* View-to-purchase rate per variant. This is the headline number.
* Average revenue per paying user per variant — protects against a variant that converts more people into a worse plan.
* Trial-start rate per variant, if the product has a trial.
* Refund and cancel rate per variant over the following 30 days. A variant that converts well but refunds high is a net loss.

## Related

* [Campaigns](/user-guide/campaigns) — the campaign types used here
* [Testing and rollout](/user-guide/testing-and-rollout) — using Draft state and instant rollback
* [Campaign delivery](/concepts/campaign-delivery) — how session-start and event-triggered delivery differ
* [Tracking events](/developer-guide/tracking-events) — tagging purchase events with the variant
* [Post-trial recovery](/recipes/post-trial-recovery) — a related recipe that also swaps paywalls by context
* [AI-assisted integration](/getting-started/ai-assisted-integration) — describe this campaign in plain language and have your AI assistant build it


# Post-trial recovery

Catch users the first time they open the app after their trial ended and show a recovery paywall instead of the default one.

**Use this when** your product has a free trial and you see a measurable cohort that lapses without converting. **Don't use this when** the app has no trial, or when lapsed users should be treated identically to free users — this recipe only pays off if the recovery paywall differs from the default.

## Goal

A user opens the app on day 8, the trial ended on day 7, and the default paywall appears as it always has. You have one shot to re-sell them and you are using the same creative that did not convert them on day 1. This recipe detects the trial-ended state, fires a dedicated recovery flow with a different offer, and stops bothering them once they either convert or clearly churn.

## Setup

### In the dashboard

* Create a campaign `post-trial-recovery`.
* Trigger: session start.
* Conditions: `trial_status = "expired"` and `subscription_status = "free"` and `sessions_since_trial_end < 5`.
* Action: fire `yourapp://paywall/recovery` — a paywall that leads with a discounted annual offer or a one-time coupon.
* Frequency cap: one show per session, maximum three total.
* Create a follow-up campaign `post-trial-recovery-final` with the same trigger but `sessions_since_trial_end >= 5` and a sharper offer. Cap it at one show, ever.
* End state: when the user subscribes, set `subscription_status = "pro"`. Both campaigns stop matching.

### In the app (engineering hand-off)

* When your billing library reports trial expiration, update the user attributes:

  ```kotlin
  amply.setCustomProperty("trial_status", "expired")
  amply.setCustomProperty("subscription_status", "free")
  amply.setCustomProperty("trial_ended_at", trialEndDate)
  ```
* Track a session counter that increments only while `trial_status = "expired"`:

  ```kotlin
  amply.setCustomProperty("sessions_since_trial_end", count)
  ```
* Register the `yourapp://paywall/recovery` deeplink to present the recovery screen.

## How it runs

1. User installs, starts a 7-day trial. `trial_status = "active"`.
2. Day 7: billing library fires trial-ended. App sets `trial_status = "expired"`, `subscription_status = "free"`, `sessions_since_trial_end = 0`.
3. Day 8: user opens the app. Session starts. Counter is now `1`. `post-trial-recovery` matches. Amply fires the recovery deeplink. User sees the 40%-off annual offer instead of the default paywall.
4. User dismisses. Counter becomes `2`, `3`, `4` over the next few sessions. Campaign continues to match (counter stays below 5), capped at three total shows.
5. On the session where the counter reaches 5, `post-trial-recovery` no longer matches and `post-trial-recovery-final` does. A last-chance offer appears.
6. User subscribes. App sets `subscription_status = "pro"`. Neither campaign matches from this session onward.

## Metrics to watch

* Recovery paywall view-to-purchase rate. Compare against default paywall conversion for the same cohort.
* Days-to-conversion after trial end. Shorter is better.
* Share of trial-ended users who ever return. If this is <20%, the recovery window is wasted; move the effort to push or email.
* Long-term retention of recovered subscribers versus first-session subscribers. A bad recovery offer buys expensive churn.

## Related

* [Paywall versioning](/recipes/paywall-versioning) — swap the recovery paywall variant itself
* [Reactivation after inactivity](/recipes/reactivation-after-inactivity) — the same pattern for users with no trial signal
* [Campaigns](/user-guide/campaigns) — frequency caps and session-start triggers
* [User attributes](/concepts/user-attributes) — modeling trial and subscription state
* [User attributes how-to](/developer-guide/user-attributes) — writing the attributes from the app
* [AI-assisted integration](/getting-started/ai-assisted-integration) — describe this campaign in plain language and have your AI assistant build it


# Reactivation after inactivity

Detect when a user returns after a quiet stretch and route them into a welcome-back flow instead of the normal home screen.

**Use this when** you want a calendar-based signal — days since last open — and a coarse "they drifted" cutoff is good enough to act on. **Don't use this when** you have behavioral data that can detect slowdowns before the calendar gap opens; reach for [Winback at-risk users](/recipes/winback-at-risk) instead. Also skip for apps with high daily-use cadence where a 7-day gap is noise.

## Goal

A user who returns after a 14-day gap is effectively a new user again — they have forgotten where things are, what the value was, and why they were paying. Dropping them on the home screen wastes the moment. This recipe fires a reactivation flow on the first session back: a short recap, a contextual offer, or a reminder of the one feature they used most.

## Setup

### In the dashboard

* Create a campaign `reactivation-inactive-7d`.
* Trigger: session start.
* Conditions: `days_since_last_session >= 7` and `days_since_last_session < 30` and `subscription_status = "free"`.
* Action: fire `yourapp://reactivation/welcome-back` with a trial extension or a feature recap.
* Create a second campaign `reactivation-inactive-30d` with `days_since_last_session >= 30`. Use a stronger offer; these users are closer to uninstall.
* Frequency: once per returning session. A user who comes back, leaves again, and returns a week later should see it fresh.
* Exclude paying users unless you specifically want to retain lapsed payers.

### In the app (engineering hand-off)

* On every session start, compute the gap from the last session timestamp and write it before Amply evaluates campaigns:

  ```swift
  amply.setCustomProperty(key: "days_since_last_session", value: gapDays)
  amply.setCustomProperty(key: "last_core_feature", value: lastFeatureUsed)
  ```
* Register `yourapp://reactivation/welcome-back` to present the recap screen. Populate it with `last_core_feature` so the screen refers to something the user actually did.
* Track the outcome:

  ```swift
  amply.track("ReactivationShown")
  amply.track("ReactivationConverted")
  ```

## How it runs

1. User installs, uses the app for a week, then stops.
2. Fourteen days later, the user opens the app. SDK starts a session.
3. App reads `last_session_at` from local storage, computes `days_since_last_session = 14`, writes it as a user attribute.
4. Amply evaluates campaigns. `reactivation-inactive-7d` matches.
5. Amply fires `yourapp://reactivation/welcome-back`. The app shows the recap screen with the user's previous favorite feature and a trial-extension button.
6. User either converts (app tracks `ReactivationConverted` and the campaign stops matching for this session) or dismisses. Either way, next time the user opens the app within a few days, the gap is small and the campaign no longer matches.

## Metrics to watch

* Return-session-to-conversion rate. Compare the cohort that saw the reactivation flow to a hold-out.
* 7-day retention after the reactivation session. The point of the flow is to restart the habit, not just extract one purchase.
* Uninstall rate in the 72 hours after the flow. A pushy offer can accelerate churn it was meant to prevent.
* Feature re-engagement: did the user actually use `last_core_feature` again in this session?

## Related

* [Post-trial recovery](/recipes/post-trial-recovery) — reactivation for the trial-specific case
* [Winback at-risk users](/recipes/winback-at-risk) — a finer-grained version triggered by a behavioral signal, not a calendar gap
* [Sessions and events](/concepts/sessions-and-events) — how session boundaries are defined
* [Targeting an audience](/user-guide/targeting-an-audience) — composing the inactivity condition
* [Tracking events](/developer-guide/tracking-events) — instrumenting the reactivation outcome
* [AI-assisted integration](/getting-started/ai-assisted-integration) — describe this campaign in plain language and have your AI assistant build it


# Seasonal campaigns

Ship a time-bound campaign — Black Friday, World Cup, app anniversary — by activating and cancelling a dashboard campaign on a planned calendar.

**Use this when** the promotion has a clear start and end date and you want to avoid shipping two app releases around it. **Don't use this when** the offer is permanent; evergreen offers belong in the default paywall, not a campaign.

## Goal

Seasonal promos historically mean a rushed release the week before, a second release to take the banner down, and a third if the end date slips. This recipe replaces the release cycle with a single dashboard campaign that an operator activates on the start date and cancels on the end date — no code ships between the two dates.

## Setup

### In the dashboard

* Create a campaign `bf-2026-annual-50off` and keep it in `Draft` state while you prepare it.
* Trigger: session start, plus a second campaign triggered on `CheckoutOpened` so the user sees the banner both passively and at intent.
* Audience: free users in markets where the offer is valid (`country` in `["US", "CA", "GB", "DE"]`).
* Action: fire `yourapp://paywall/black-friday` pointing to a holiday-themed paywall with the 50%-off annual SKU.
* Frequency: once per session, capped at four sessions total.
* On the planned start date, move the campaign to `Active`. On the end date, move it to `Cancel`. The default paywall campaign stays running throughout and reclaims every session once the seasonal campaign is cancelled.

### In the app (engineering hand-off)

* Ship the seasonal paywall screen in a release before the start date. Remote scheduling cannot introduce new screens.
* Ensure the correct SKU is approved in App Store Connect and Google Play well before the start date.
* Register the `yourapp://paywall/black-friday` deeplink.
* Set region on the user so targeting works:

  ```typescript
  await Amply.setCustomProperty('country', deviceCountry);
  ```

## How it runs

1. Before November 24, the campaign sits in `Draft`. The default paywall runs normally.
2. On the morning of November 24, the operator moves the campaign to `Active`. Users in the target markets who open the app from then on see the seasonal paywall.
3. During the window, users can hit the paywall up to four times across sessions. After that, the frequency cap silences it and the default paywall returns for that user.
4. A user outside the target countries never matches. They continue to see the default paywall throughout.
5. On December 2, the operator moves the campaign to `Cancel`. The default paywall immediately reclaims every session.
6. The team reviews the campaign report in the dashboard and decides whether to re-run the same campaign with tweaks for Cyber Monday next year.

## Metrics to watch

* View-to-purchase rate during the window versus the default paywall in the same window a year prior.
* Share of revenue from the seasonal SKU — cannibalization matters. If monthly revenue drops as annual-50off rises, check whether you pulled forward demand.
* Post-window cancel rate at day 30 and day 60. Discount-led cohorts often churn earlier.
* Regional split. If one country converts dramatically worse, the creative or the localization is off.

## Related

* [Paywall versioning](/recipes/paywall-versioning) — the underlying variant pattern this recipe relies on
* [Testing and rollout](/user-guide/testing-and-rollout) — previewing the campaign before it goes live
* [Creating a campaign](/user-guide/creating-a-campaign) — moving a campaign through Draft → Active → Cancel
* [Targeting an audience](/user-guide/targeting-an-audience) — country and segment filters
* [Campaign delivery](/concepts/campaign-delivery) — how overlapping campaigns are evaluated
* [AI-assisted integration](/getting-started/ai-assisted-integration) — describe this campaign in plain language and have your AI assistant build it


# Rate review at a positive moment

Show the native rate-review prompt only to users who have just signaled satisfaction — not to everyone on session 3.

**Use this when** your store rating is dragged down by users asked at the wrong moment. **Don't use this when** you have no reliable "positive moment" event. Firing at a random session is no better than the stock prompt.

## Goal

The stock "rate us" prompt on session 3 pulls from the whole population, including frustrated users. Those users rate 1 and 2 stars. This recipe gates the native prompt behind a light custom popup that first asks "how's it going?" — and only triggers the store rating if the user says it is going well. The effect is a higher average store rating because you never invite the unhappy cohort to the store at all.

## Setup

### In the dashboard

* Create a campaign `rating-prompt-positive-moment`.
* Trigger: event `PositiveSignal` (a value moment you define — streak completed, goal hit, purchase confirmed).
* Conditions: `subscription_status != "new"` (avoid brand-new users), `store_rating_shown != true`, session count `>= 3`.
* Action: fire a deeplink to your in-app sentiment popup, e.g. `yourapp://popup/sentiment-check`. The popup asks "Enjoying the app?" with two buttons: "Yes" / "Not really". See [Custom popups](/user-guide/custom-popups) for how the popup-as-deeplink pattern works today.
* Branch on the popup result (handled in-app, not in the dashboard):
  * "Yes" → the app calls the native rate-review prompt.
  * "Not really" → the app opens `yourapp://feedback/form` (see [Feedback capture on negative rating](/recipes/feedback-capture-on-negative-rating)).
* Frequency: once per user, ever — enforced by the `store_rating_shown != true` condition in the audience, not a native frequency cap.

### In the app (engineering hand-off)

Note: `session_count` is not a built-in targeting attribute — the app must increment and set it as a custom property on each session start, e.g. `amply.setCustomProperty("session_count", n)`.

* Pick the single strongest "positive moment" in your product. Track it as one event, not many:

  ```swift
  amply.track("PositiveSignal", properties: ["source": "streak_7d"])
  ```
* Register a popup action that calls `SKStoreReviewController.requestReview()` on iOS or `ReviewManager` on Android when Amply asks.
* After the native prompt runs, mark the user:

  ```swift
  amply.setCustomProperty(key: "store_rating_shown", value: true)
  ```

## How it runs

1. User completes a 7-day streak. App fires `PositiveSignal`.
2. Amply evaluates event-triggered campaigns. The user has session count `5`, has never been asked, is not a new user. `rating-prompt-positive-moment` matches.
3. Custom popup appears: "Enjoying the app?"
4. User taps "Yes". Amply triggers the native rate-review prompt. The OS shows its store rating sheet.
5. User leaves a 5-star review. App sets `store_rating_shown = true`. The campaign never matches this user again.
6. Parallel path: if the user had tapped "Not really", the feedback deeplink fires and the native prompt is never called — the unhappy user never reaches the store.

## Metrics to watch

* Store rating distribution before and after rollout. Expect the 4-5 star share to rise and the 1-2 star share to fall.
* Popup "Yes" share. If it is <50%, the `PositiveSignal` event is triggering too broadly.
* Native prompt acceptance rate among those who said "Yes". This is the lift over the stock flow.
* Written review volume. You may see fewer total reviews but a higher average — that is the desired outcome.

## Related

* [Feedback capture on negative rating](/recipes/feedback-capture-on-negative-rating) — the "Not really" branch
* [Rate-review flows](/user-guide/rate-review-flows) — dashboard configuration for the native prompt
* [Custom popups](/user-guide/custom-popups) — building the "Enjoying the app?" popup
* [Tracking events](/developer-guide/tracking-events) — defining the `PositiveSignal` event
* [Sessions and events](/concepts/sessions-and-events) — when event-triggered campaigns evaluate
* [AI-assisted integration](/getting-started/ai-assisted-integration) — describe this campaign in plain language and have your AI assistant build it


# Feedback capture on negative rating

Keep 1- and 2-star reviews out of the App Store by catching them inside the app and routing the user to a feedback form instead.

**Use this when** you run the rate-review positive-moment flow and want to do something useful with the unhappy branch. **Don't use this when** you do not have an internal channel to read and answer the feedback; silent intake makes the user angrier.

## Goal

If you only ask happy users to rate you, you never hear from the unhappy ones — and they eventually leave 1-star reviews anyway, just with less data for you. This recipe catches the "not really" branch of the rating popup, opens a short in-app form, captures the complaint with a reason category, and closes the loop with a confirmation. The public rating is protected and you now have actionable signal.

## Setup

### In the dashboard

* This recipe is the negative branch of [rate-review-positive-moment](/recipes/rate-review-positive-moment). It reuses that recipe's sentiment popup.
* When the user taps "Not really" in the popup, the app opens `yourapp://feedback/form`. No additional campaign is needed for this step — the app handles the branch locally.
* Create a follow-up campaign `feedback-thanks` triggered by the event `FeedbackSubmitted`.
  * Action: fire deeplink `yourapp://popup/feedback-thanks` — the app renders a "Thanks — we'll get back to you" popup with a single OK button (see [Custom popups](/user-guide/custom-popups)).
  * Frequency: once per submission.
* Do not fire the native store prompt on this branch under any circumstance.

### In the app (engineering hand-off)

* Register the `yourapp://feedback/form` deeplink to open an in-app form with a reason selector (bug, missing feature, price, other) and a free-text field.
* On submit, send the payload to your support inbox or help-desk and fire:

  ```typescript
  await Amply.track({
    name: 'FeedbackSubmitted',
    properties: { reason: selectedReason, rating_bucket: 'low' }
  });
  ```
* Suppress the native rate-review call on this branch. The positive-moment recipe's branching already does that.

## How it runs

1. User hits a positive moment. The rating popup appears: "Enjoying the app?"
2. User taps "Not really".
3. The app opens `yourapp://feedback/form` locally — no campaign is involved in this step. The in-app form opens. The user picks "missing feature" and types one sentence.
4. App sends the payload to the support inbox. App fires `FeedbackSubmitted` with the reason.
5. `feedback-thanks` matches. The confirmation popup appears.
6. Support triages the feedback. If it is a bug the team can fix, the user is followed up through the same channel. The native store prompt was never called. The App Store rating stays intact.

## Metrics to watch

* Share of `PositiveSignal` firings that end in the negative branch. Rising share means something in the product got worse — this is now your canary.
* Feedback submission rate once the form opens. <30% means the form is too long or the moment is wrong.
* Reason-code distribution. This is the cheapest user-research feed in the product.
* 1- and 2-star review count in the public stores week-over-week.

## Related

* [Rate-review at a positive moment](/recipes/rate-review-positive-moment) — the other half of this flow
* [Custom popups](/user-guide/custom-popups) — building the confirmation popup
* [Managing deeplinks](/user-guide/managing-deeplinks) — registering the feedback form route
* [Tracking events](/developer-guide/tracking-events) — firing `FeedbackSubmitted` with the reason property
* [Handling deeplinks](/developer-guide/handling-deeplinks) — opening the form from the deeplink listener
* [AI-assisted integration](/getting-started/ai-assisted-integration) — describe this campaign in plain language and have your AI assistant build it


# Second-chance paywall

Catch users on their second or third session with a limited-time discount before they uninstall — the mobile equivalent of cart abandonment.

**Use this when** first-session paywall conversion is your main funnel and dismissal is your biggest leak. **Don't use this when** your paywall is shown deep in the app and most users do not see one on session 1; the "second chance" framing does not apply.

## Goal

A user sees your paywall on session 1, dismisses it, and leaves. The chance that they go open the iOS or Play settings, find your subscription, and sign up on their own is effectively zero. This recipe detects the returning free user on session 2 or 3 and shows a time-limited offer at app start — a 50%-off first month, or a one-time coupon. You are catching the user before uninstall and giving them a reason to reconsider.

## Setup

### In the dashboard

* Create a campaign `second-chance-paywall`.
* Trigger: session start.
* Conditions: `subscription_status = "free"` and `session_count` in `[2, 3]` and `paywall_dismissed_count >= 1`.
* Action: fire `yourapp://paywall/second-chance` — a dedicated screen with a countdown timer and the 50%-off product.
* Frequency: once per session, up to two total shows across sessions 2 and 3.
* After session 3, the user no longer matches. Do not keep hammering them — that is how you earn uninstalls.

### In the app (engineering hand-off)

Note: `session_count` is not a built-in targeting attribute — the app must increment and set it as a custom property on each session start, e.g. `amply.setCustomProperty("session_count", n)`.

* Track dismissals so the targeting condition works:

  ```swift
  amply.track("PaywallDismissed", properties: ["variant": "default"])
  amply.setCustomProperty(key: "paywall_dismissed_count", value: dismissedCount)
  ```
* Register `yourapp://paywall/second-chance` to the offer screen. The screen owns its countdown locally; the campaign only decides when to open it.
* Ensure the 50%-off SKU is approved and fetched on launch. A paywall with a broken price tag destroys trust immediately.

## How it runs

1. Session 1: user opens the app, sees the default paywall, dismisses it. App fires `PaywallDismissed` and increments `paywall_dismissed_count` to 1.
2. User closes the app. Does not uninstall, yet.
3. Session 2, next day: app opens. Session starts. Conditions match — free, session 2, dismissed once. `second-chance-paywall` matches.
4. Amply fires `yourapp://paywall/second-chance`. The discounted offer appears before the home screen loads.
5. User either converts (app sets `subscription_status = "pro"`, campaign stops matching) or dismisses again.
6. Session 3: last chance to match. After that, the campaign is silent. The user either returns organically, churns, or hits a different campaign later.

## Metrics to watch

* Conversion on session 2 and session 3 compared to a hold-out group that only sees the default paywall.
* Uninstall rate in the 48 hours after session 2. A pushy second-chance paywall can accelerate the churn it is trying to prevent.
* Revenue per returning session versus the baseline.
* Downstream retention of second-chance converters at day 30 and 60. Discount-led subscribers usually retain worse; budget for it.

## Related

* [Paywall versioning](/recipes/paywall-versioning) — shipping and testing the second-chance screen itself
* [Post-trial recovery](/recipes/post-trial-recovery) — the trial-specific cousin of this recipe
* [Feature-wall contextual paywall](/recipes/feature-wall-contextual-paywall) — a complementary pattern triggered on feature intent, not session count
* [User attributes](/concepts/user-attributes) — modeling dismissal counts on the user
* [Creating a campaign](/user-guide/creating-a-campaign) — building the session-count condition
* [AI-assisted integration](/getting-started/ai-assisted-integration) — describe this campaign in plain language and have your AI assistant build it


# Soft push permission

Ask for push permission inside a custom popup first, so users who would decline never see the OS prompt.

**Use this when** push is core to your retention strategy and the stock "allow notifications" rate is under 50%. **Don't use this when** you do not have a clear value story for push. Pre-asking for nothing-in-particular is worse than the default flow.

## Goal

The OS push prompt can only be shown once. If the user taps "Don't Allow," they are effectively unreachable forever — pushing them into app settings to flip it back is not a retention strategy. This recipe shows your own popup first, with copy that explains what the user gets. Only users who tap "Yes, notify me" trigger the real OS prompt. The ones who would have tapped "Don't Allow" are silently queued for a better moment.

## Setup

### In the dashboard

* Create a campaign `push-softask`.
* Trigger: event `ValueMomentReached` — not session start, and not right after install. Wait until the user has seen something worth pushing about.
* Conditions: `push_permission_status = "unknown"` (never asked yet).
* Action: fire a deeplink to your in-app soft-ask popup, e.g. `yourapp://popup/push-softask`. The popup presents a clear value prop ("Get daily streak reminders?") and two buttons: "Yes, notify me" / "Not now". See [Custom popups](/user-guide/custom-popups) for the popup-as-deeplink pattern.
* The app handles branching locally:
  * "Yes, notify me" → the app tracks `PushSoftAskApproved` and calls the OS prompt.
  * "Not now" → the app tracks `PushSoftAskDeclined`. The OS prompt is not called.
* Frequency: once per user until declined, then one retry 14 days later if value has been delivered since (use a `push_softask_declined_at` attribute in the audience filter).

### In the app (engineering hand-off)

Note: `session_count` is not a built-in targeting attribute — if you gate the campaign on session count (for instance, to wait past a new-user window), the app must increment and set it as a custom property on each session start, e.g. `amply.setCustomProperty("session_count", n)`.

* Listen for the approved event and call the OS prompt only then:

  ```swift
  // on PushSoftAskApproved
  UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, _ in
    amply.setCustomProperty(key: "push_permission_status", value: granted ? "granted" : "denied")
  }
  ```
* On "Not now," keep `push_permission_status = "unknown"` (the OS prompt is preserved for later) and record the decline time so the 14-day retry window can be computed: `amply.setCustomProperty("push_softask_declined_at", now)`.
* Track the soft-ask outcome for analysis:

  ```swift
  amply.track("PushSoftAskShown")
  amply.track("PushSoftAskApproved")
  amply.track("PushSoftAskDeclined")
  ```

## How it runs

1. User installs the app. No push prompt fires. `push_permission_status = "unknown"`.
2. User uses the app and hits a meaningful moment. App tracks `ValueMomentReached`.
3. Amply evaluates event-triggered campaigns. `push-softask` matches.
4. Custom popup appears: "Get daily streak reminders?" with two buttons.
5. User taps "Yes, notify me." App fires `PushSoftAskApproved`. The OS prompt appears. User taps "Allow." `push_permission_status = "granted"`.
6. Parallel path: user taps "Not now." App fires `PushSoftAskDeclined`. The OS prompt never runs. Two weeks later, after another value moment, the campaign can re-match once with a different copy line.

## Metrics to watch

* Net push opt-in rate — soft-ask approved AND OS granted — compared to the rate you get by calling the OS prompt directly.
* Soft-ask show rate per user. If the popup fires too close to install, the whole gain vanishes.
* Decline-then-accept rate on the 14-day retry. If this is non-zero, the retry is worth keeping.
* Push-driven session count after rollout. The reason you do this is downstream sessions, not the permission number itself.

## Related

* [Custom popups](/user-guide/custom-popups) — building the soft-ask popup
* [Campaigns](/user-guide/campaigns) — event-triggered campaigns
* [Tracking events](/developer-guide/tracking-events) — firing the soft-ask events
* [User attributes](/concepts/user-attributes) — storing permission state as a user attribute
* [Campaign delivery](/concepts/campaign-delivery) — frequency caps and retry windows
* [AI-assisted integration](/getting-started/ai-assisted-integration) — describe this campaign in plain language and have your AI assistant build it


# Feature-wall contextual paywall

Show a paywall tailored to the specific Pro feature the user just tried to use, instead of a generic one.

**Use this when** your product has several distinct Pro features and users try different ones; a single headline paywall underserves all of them. **Don't use this when** there is only one Pro feature worth selling; the generic paywall already talks about it.

## Goal

A generic "Upgrade to Pro" paywall has to speak to everyone, so it speaks to nobody. The user who just tapped "Export to PDF" is not shopping for the abstract idea of premium — they want to export a PDF right now. This recipe intercepts the feature tap, fires a paywall whose hero section is about that exact feature, and preserves the user's intent to complete the export on conversion.

## Setup

The recommended primary shape is a **single campaign** that handles every gated feature and lets the app pick the matching paywall screen locally. It keeps the dashboard small even when you ship more Pro features. (See [Variants](#variants) for a per-feature alternative.)

### In the dashboard

* Create one campaign `feature-wall`.
* Trigger: event `FeatureGateTriggered`. No property filter on `feature` — you will branch in the app.
* Conditions: `subscription_status = "free"`.
* Action: fire `yourapp://paywall/feature-wall?feature={{feature}}`. The app reads the `feature` query parameter and chooses the right paywall screen.
* Frequency: unlimited — the user keeps hitting features; keep offering the relevant paywall.
* On conversion, resume the blocked action (see engineering hand-off).

### In the app (engineering hand-off)

* On a Pro feature tap, track the gate event with a `feature` property instead of showing a generic paywall:

  ```typescript
  await Amply.track({
    name: 'FeatureGateTriggered',
    properties: { feature: 'pdf_export', source_screen: 'report_detail' }
  });
  ```
* Register a single deeplink handler for `yourapp://paywall/feature-wall`. Read the `feature` query parameter and route to the matching paywall screen locally.
* Ship each contextual paywall screen in the build before the campaign goes live. The deeplink cannot introduce new screens.
* When the user converts, resume the feature action the user originally tapped. Do not dump them back on the home screen.

## How it runs

1. Free user taps "Export to PDF" on a report screen.
2. App fires `FeatureGateTriggered { feature: "pdf_export", source_screen: "report_detail" }`.
3. Amply evaluates event-triggered campaigns. `feature-wall` matches.
4. Amply fires `yourapp://paywall/feature-wall?feature=pdf_export`. The app reads `feature=pdf_export` and opens the PDF-export paywall screen with its own hero and preview.
5. User taps subscribe, completes purchase, `subscription_status` becomes `"pro"`.
6. App returns the user to the report screen and completes the export automatically.
7. A different free user taps "Offline mode." Same flow — `feature-wall` matches again, the deeplink carries `feature=offline_mode`, and the app opens the offline-mode paywall screen instead.

## Variants

If you need to target or schedule paywalls per feature independently — for example, running a limited-time discount only on the export-to-PDF paywall — use one campaign per feature:

* `feature-wall-pdf-export`, `feature-wall-offline-mode`, `feature-wall-custom-themes`.
* Each campaign triggers on `FeatureGateTriggered` with a `feature = "pdf_export"` property filter and fires its own deeplink (`yourapp://paywall/pdf-export`, etc.).
* Each contextual paywall can be activated, cancelled, or re-audienced independently.

This scales poorly as the Pro feature set grows — prefer the single-campaign variant unless you need per-feature control.

## Metrics to watch

* Conversion rate per feature gate. Compare to the generic paywall baseline — expect significant lift on the features users actually reach for.
* Gate-to-convert time. Contextual paywalls usually convert faster because the intent is already there.
* Top-converting features. This data tells you which Pro features to invest more in.
* Post-purchase action completion. If most converters never actually export a PDF after subscribing, the intent-preservation path is broken.

## Related

* [Paywall versioning](/recipes/paywall-versioning) — shipping and testing each contextual paywall
* [Second-chance paywall](/recipes/second-chance-paywall) — complementary, triggered by session count instead of feature intent
* [Campaigns](/user-guide/campaigns) — configuring event-triggered campaigns
* [Tracking events](/developer-guide/tracking-events) — the `FeatureGateTriggered` pattern
* [Handling deeplinks](/developer-guide/handling-deeplinks) — resuming the gated action after conversion
* [AI-assisted integration](/getting-started/ai-assisted-integration) — describe this campaign in plain language and have your AI assistant build it


# Feature feedback capture

Ask a targeted feedback question after a user has used a new feature twice — while the memory is fresh and the sample is qualified.

**Use this when** you have shipped a new feature and need real signal on whether it is landing before committing to more work on it. **Don't use this when** the feature has not shipped to enough users yet; the popup firing is cheap, but drawing conclusions from ten answers is not.

## Goal

You ship a new section. Support is quiet, analytics shows some usage, and you have no idea whether users love it, find it confusing, or cannot find what they were looking for. Running a user-research round takes two weeks. This recipe drops a three-button popup on users who have engaged with the feature enough to have an opinion, captures the answer, and ships it to your analytics tool. You get a coarse but honest read in 48 hours.

## Setup

### In the dashboard

* Create a campaign `new-section-feedback`.
* Trigger: event `NewSectionUsed` with `usage_count = 2`.
* Conditions: `new_section_feedback_given != true`.
* Action: fire a deeplink to your in-app feedback popup, e.g. `yourapp://popup/new-section-feedback`. The popup asks "How's the new section?" with three buttons: "Love it", "Confusing", "Couldn't find what I wanted". See [Custom popups](/user-guide/custom-popups) for the popup-as-deeplink pattern.
* Each button tap is handled in-app: the app tracks the answer event and dismisses the popup. No further navigation.
* Frequency: once per user, ever — enforced by the `new_section_feedback_given != true` condition in the audience, not a native frequency cap.

### In the app (engineering hand-off)

* Increment a usage counter and fire an event each time the feature is used:

  ```kotlin
  val count = currentCount + 1
  amply.setCustomProperty("new_section_usage_count", count)
  amply.track("NewSectionUsed", mapOf("usage_count" to count))
  ```
* On each popup button tap, track the answer and mark the user as answered:

  ```kotlin
  amply.track("NewSectionFeedback", mapOf("answer" to "love_it"))
  amply.setCustomProperty("new_section_feedback_given", true)
  ```
* Forward `NewSectionFeedback` to your analytics tool. That is where the team will read the split.

## How it runs

1. User opens the new section for the first time. App increments the counter and fires `NewSectionUsed { usage_count: 1 }`. No campaign matches — the event trigger requires `usage_count = 2`.
2. Later that day, user opens the new section again. `usage_count = 2`. `new-section-feedback` matches.
3. Custom popup appears: "How's the new section?"
4. User taps "Confusing." App fires `NewSectionFeedback { answer: "confusing" }` and sets `new_section_feedback_given = true`.
5. The popup closes. The user continues using the app.
6. Over the next week, several hundred users answer. The product team reads the "Confusing" share in analytics and opens a ticket on whichever screen ranks worst for that cohort.

## Metrics to watch

* Response-rate of the popup. A healthy range is 40-70%. Under that and the moment is wrong or the copy is off.
* Answer distribution. The absolute numbers matter less than the shape: "Couldn't find what I wanted" high means the information architecture is off.
* Correlation with retention. Do "Love it" responders have higher 30-day retention than "Confusing" responders? That is the test of whether the feedback is predictive.
* Time to first answer after feature launch. Fast feedback is the point of this recipe.

## Related

* [Custom popups](/user-guide/custom-popups) — building the three-button popup
* [Campaigns](/user-guide/campaigns) — event-triggered campaigns with usage-count conditions
* [Tracking events](/developer-guide/tracking-events) — firing `NewSectionUsed` and `NewSectionFeedback`
* [User attributes](/concepts/user-attributes) — marking `new_section_feedback_given` to prevent re-asking
* [Feedback capture on negative rating](/recipes/feedback-capture-on-negative-rating) — the rating-driven cousin of this recipe
* [AI-assisted integration](/getting-started/ai-assisted-integration) — describe this campaign in plain language and have your AI assistant build it


# Winback for at-risk users

Catch users whose behavior pattern is decaying — before they fully churn — and route them to a winback screen on their next session.

**Use this when** you have behavioral data — session frequency, feature usage, cohort baselines — that can flag "slowing down" before a calendar gap opens. **Don't use this when** you only have calendar signal (last-open timestamp); reach for [Reactivation after inactivity](/recipes/reactivation-after-inactivity) instead. Also skip if the product is used every few days under normal conditions — the baseline is too shallow to drift against.

## Goal

Churn prediction that waits for 30 days of inactivity is too late — by the time you act, the user has the app in an uninstall folder. This recipe fires on the next session after a behavioral drop (for example, weekly sessions fell from 5 to 1) and shows a winback surface: a value-recap screen, a small incentive, or a shortcut to the feature the user used to love. You are intervening while the user still has the app on their home screen.

## Setup

### In the dashboard

* Create a campaign `winback-at-risk`.
* Trigger: session start.
* Conditions: `risk_status = "at_risk"` and `subscription_status != "pro_new"` (do not hassle users who just paid).
* Action: fire `yourapp://winback/welcome-back` — a short screen that references the user's previous favorite feature and offers a small incentive if appropriate.
* Frequency: once per transition into `at_risk`. When the user re-engages and `risk_status` flips back to `healthy`, the campaign can match again if they slip later.
* Exclude users whose `last_session_at` is more than 30 days old — those need a different flow in a different channel.

### In the app (engineering hand-off)

* Compute the risk signal in the app or an upstream service. Keep the definition simple and stable:

  ```typescript
  // Pseudocode: compare last 7 days to prior 28-day baseline
  const status = recentRate < baseline * 0.3 ? 'at_risk' : 'healthy';
  await Amply.setCustomProperty('risk_status', status);
  await Amply.setCustomProperty('last_core_feature', lastFeature);
  ```
* Write `risk_status` before the session starts, so the first campaign evaluation sees the current value.
* Track the outcome for measurement:

  ```typescript
  await Amply.track({ name: 'WinbackShown' });
  await Amply.track({ name: 'WinbackConverted' });
  ```

## How it runs

1. User used the app \~5 times per week for a month, then dropped to \~1 per week.
2. On the first session after the drop, the app recomputes the risk signal: `risk_status = "at_risk"`.
3. Session starts. Amply evaluates campaigns. `winback-at-risk` matches.
4. Amply fires `yourapp://winback/welcome-back`. The screen shows "Your weekly streak is still here — one tap to pick up where you left off," referring to the user's last core feature. The app tracks `WinbackShown`.
5. User taps through, uses the feature again. Over the next week, usage recovers. The app recomputes `risk_status = "healthy"`.
6. If usage drops again months later, the campaign can match again. If the user goes dark for 30+ days, they fall out of this campaign's audience and belong to reactivation or email instead.

## Metrics to watch

* Winback conversion rate: share of `WinbackShown` users who come back to healthy within 14 days. Compare to a hold-out that never saw the screen.
* False-positive rate of the at-risk signal: users flagged at-risk who would have recovered anyway. A noisy signal burns the surface.
* Long-term retention of winback-recovered users — 60 and 90 days out. A superficial recovery does not count.
* Share of your free-to-paid users who ever hit at-risk. Rising share is a product-quality signal, not a campaign problem.

## Related

* [Reactivation after inactivity](/recipes/reactivation-after-inactivity) — the coarser calendar-based version, used when you have no behavioral signal
* [Post-trial recovery](/recipes/post-trial-recovery) — for trial users specifically
* [Targeting an audience](/user-guide/targeting-an-audience) — expressing `risk_status` conditions
* [User attributes](/concepts/user-attributes) — why risk status belongs on the user
* [Tracking events](/developer-guide/tracking-events) — winback event instrumentation
* [AI-assisted integration](/getting-started/ai-assisted-integration) — describe this campaign in plain language and have your AI assistant build it


# Referral at a positive moment

Fire the invite-a-friend screen at the exact moment the user just had a good experience — not from a static menu that nobody opens.

**Use this when** you run a referral program whose K-factor is low because the invite CTA is buried in settings. **Don't use this when** you have no working referral program to route users into. Surfacing a broken flow is worse than hiding it.

## Goal

Users recommend apps when they feel good about them, and that feeling decays within minutes. A Settings > Invite Friends link captures almost nobody because it requires intent; the invite screen has to come to the user. This recipe fires the invite surface on a tracked positive moment — a milestone, a big win, a streak — and only shows it once, because the second ask after a good moment is worse than no ask at all.

## Setup

### In the dashboard

* Create a campaign `referral-positive-moment`.
* Trigger: event `PositiveMoment` with property `kind` in a defined allowlist ("streak\_7d", "big\_win", "first\_export", etc.).
* Conditions: `session_count >= 5`, `has_referred != true`, `subscription_status != "new"`.
* Action: fire `yourapp://invite-friends`, a dedicated invite screen pre-populated with a share message and code.
* Frequency: the `has_referred != true` audience condition suppresses the campaign permanently once the user shares. A user who *dismisses* without sharing keeps `has_referred = false` and may be re-asked at a later positive moment — add a native frequency cap (for example, once per 30 days) if you want to bound that.

### In the app (engineering hand-off)

Note: `session_count` is not a built-in targeting attribute — the app must increment and set it as a custom property on each session start, e.g. `amply.setCustomProperty("session_count", n)`.

* Pick the small list of real positive moments in your product. Fire them as one event with a `kind` property:

  ```swift
  amply.track("PositiveMoment", properties: ["kind": "streak_7d"])
  ```
* Register `yourapp://invite-friends` to an invite screen that uses the platform share sheet.
* On successful share, mark the user and track the outcome:

  ```swift
  amply.setCustomProperty(key: "has_referred", value: true)
  amply.track("ReferralShared", properties: ["kind": "streak_7d"])
  ```

## How it runs

1. User hits a 7-day streak. App fires `PositiveMoment { kind: "streak_7d" }`.
2. Amply evaluates event-triggered campaigns. User has session count 12, has not referred anyone, is past the new-user window. `referral-positive-moment` matches.
3. Amply fires `yourapp://invite-friends`. The invite sheet opens over the streak celebration screen. The copy is short and references the streak.
4. User taps "Share" and picks a messaging app. App fires `ReferralShared { kind: "streak_7d" }`. App sets `has_referred = true`.
5. Next positive moment weeks later: the same campaign evaluates, sees `has_referred = true`, and does not match. The user is not asked again.
6. Parallel path: user dismisses without sharing. `has_referred` stays `false`. The campaign could match on the next positive moment of a different `kind` if you choose to allow that — or stay one-shot if you prefer.

## Metrics to watch

* Invite open rate per `kind`. Some positive moments generate far more shares than others; double down on those.
* Accept rate of sent invites. The upstream CTA cannot fix a broken referral program downstream.
* K-factor before and after rollout. If it does not move, the moment-timing is fine but the incentive is wrong.
* Share of converted referrals who stick past 30 days. Referred users retain better than paid users when the program works.

## Related

* [Rate-review at a positive moment](/recipes/rate-review-positive-moment) — the same pattern for store ratings
* [Custom popups](/user-guide/custom-popups) — for a lighter popup variant before the full invite screen
* [Creating a campaign](/user-guide/creating-a-campaign) — building the event-triggered campaign
* [Tracking events](/developer-guide/tracking-events) — firing `PositiveMoment` consistently
* [Campaign delivery](/concepts/campaign-delivery) — once-per-user frequency caps
* [AI-assisted integration](/getting-started/ai-assisted-integration) — describe this campaign in plain language and have your AI assistant build it


# Price management

Plan, preview, and submit price changes for your in-app subscriptions and one-time products across every country your app sells in, from the dashboard.

**Use this when** you want to change what users pay in one or many territories — a new base price plus an index, or a single ± percentage applied to current prices. **Don't use this when** you want to change a single product's price in a single country — do that directly in App Store Connect or Google Play Console.

## Prerequisites

* The application is connected to App Store Connect or Google Play Console, and its products and current prices are visible in Amply.
* Your plan includes Price Management. If you don't see the **Price Management** entry in the left nav, check your plan under [Billing](/user-guide/dashboard-tour#upgrade-the-plan).
* For Android subscriptions, products must have a single base plan — multi-base-plan subscriptions aren't supported yet.

## Find it in the dashboard

From the project sidebar, open **Price Management**. The landing page is **History** — every price change for the active application, drafts included. The page auto-refreshes every ten seconds while any batch is still in progress.

Two buttons at the top right of History:

* **New Price Change** — open the three-step form to create a draft.
* **Compare** — inspect current prices across up to three products side by side.

A third entry sits in the left nav under Price Management:

* **Price Indexes** — manage reusable pricing matrices.

## Pricing method, in one sentence

Two methods are available when you create a change:

* **Base price** — set one price in one country, multiply by a price index for every other country. Best for a structured update.
* **Percentage** — apply a single ± percentage to every current store price. Fastest for a uniform bump or cut.

If you're not sure which to pick, open **Compare** first to inspect current prices side by side. See [Comparing prices](/price-management/comparing-prices). For the full wizard, draft workflow, and recovery actions, see [Creating a price change](/price-management/creating-a-price-change).

## Status reference

A **price change** (a *batch* in the UI) goes through these states:

| Status     | What it means                                                  |
| ---------- | -------------------------------------------------------------- |
| Draft      | Saved but not submitted. Edit or delete at any time.           |
| Pending    | Submitted; queued for send.                                    |
| Sending    | Actively pushing territory-by-territory updates.               |
| Completed  | Every territory synced successfully.                           |
| Incomplete | Some territories synced, others failed or were deferred.       |
| Failed     | Every territory failed.                                        |
| Cancelled  | You cancelled it while in flight, or it aged out.              |
| Deferred   | Some territories are waiting on the store's rate-limit window. |

**Completed**, **Incomplete**, **Failed**, and **Cancelled** are *terminal* — no more automatic retries. You can still act manually: retry failed entries, resume deferred ones, or derive a custom index from the matrix.

Each territory inside a batch carries its own status: Pending, Sending, Completed, Failed, Skipped, Deferred, Cancelled, or Unchanged. **Unchanged** means the calculated new price matched the current store price — no write was needed.

## Store-specific behavior

**App Store (iOS)**

* Earliest start date is tomorrow — Apple rejects same-day changes.
* Base price must match an **Apple price point** (a tier price Apple accepts). The form's autocomplete restricts you to valid points for the selected base country.
* For subscriptions, you also choose an **audience**: "New subscribers only" (existing subscribers keep their current price) or "All subscribers." Price decreases apply to everyone regardless of the audience setting.

**Google Play (Android)**

* The change is accepted immediately, but can take several hours to propagate to the listing.
* Subscriptions must have a single base plan.

## Who can use it

Price Management is a dashboard-only feature — no SDK integration involved. The creator of every batch is recorded and shown in the **Created By** column of the history table.

## Related

* [Creating a price change](/price-management/creating-a-price-change) — full walk-through of the wizard, draft workflow, and recovery actions
* [Price indexes](/price-management/price-indexes) — system and custom indexes, and the CSV flow
* [Comparing prices](/price-management/comparing-prices) — inspect current prices side by side
* [Dashboard tour](/user-guide/dashboard-tour) — how to find Price Management and upgrade your plan if it's missing


# Store credentials

Create the App Store Connect and Google Play credentials Amply needs to read and update your prices — with the least-privilege access level for each store.

Amply manages your App Store and Google Play prices for you — it reads your current prices and submits scheduled price changes. To do that, it needs API credentials from each store, with permission to manage pricing. You create these once per app and can revoke them at any time in the store's console.

This page covers both platforms — jump to [App Store Connect (iOS)](#app-store-connect-ios) or [Google Play (Android)](#google-play-android).

{% hint style="info" %}
Your credentials are stored encrypted and are used only to read and update your store pricing. Grant the least-privileged role that still allows pricing — you don't need account-owner access.
{% endhint %}

## At a glance

|                           | App Store Connect (iOS)               | Google Play (Android)                         |
| ------------------------- | ------------------------------------- | --------------------------------------------- |
| What you create           | An App Store Connect **API key**      | A Google Cloud **service account** + JSON key |
| What you paste into Amply | Key ID, Issuer ID, and the `.p8` file | The service-account JSON                      |
| Access level it needs     | **App Manager** role (or higher)      | **Manage store presence** + read access       |
| Where                     | App Store Connect → Users and Access  | Google Cloud + Play Console                   |

## App Store Connect (iOS)

You'll create an **App Store Connect API key** and give Amply three things: a **Key ID**, an **Issuer ID**, and a **`.p8`** private-key file.

### Before you start

* Your app already exists in App Store Connect, under the team you'll create the key in (not a personal Apple ID).
* Your Apple agreements, tax, and banking details are complete — otherwise price changes can fail even with valid credentials.
* You can sign in to App Store Connect as an **Account Holder** or **Admin** — only these roles can create a team API key.

### Access level

The key's role must be able to manage pricing. Assign it the **App Manager** role: App Manager can manage all aspects of an app, including pricing — everything Amply needs and nothing more. **Admin** also works but grants more than necessary. The **Finance** role is **not** enough — it's read-only for pricing.

### Steps

1. In App Store Connect, go to **Users and Access → Integrations → App Store Connect API**.
2. Under **Team Keys**, select **Generate API Key** (or **+**).
3. Name it (for example, "Amply price management") and set **Access** to **App Manager**, then generate.
4. Copy the **Issuer ID** (shown above the list of keys) and the new key's **Key ID**.
5. **Download the API key (`.p8`) now** — Apple lets you download it only once.
6. In Amply, open your application's **Integrations** tab and enter the **Key ID**, **Issuer ID**, and the contents of the `.p8` file (or upload the file).

{% hint style="warning" %}
Lost the `.p8`? It can't be downloaded again — revoke that key and generate a new one.
{% endhint %}

## Google Play (Android)

You'll create a **Google Cloud service account**, download its **JSON key**, and grant it access in the Play Console.

### Before you start

* Your app already exists in Google Play Console.
* Your Play Console payments profile and agreements are complete.
* You can manage users in the Play Console (**Users and permissions**) — needed to invite the service account.

### Access level

In **Google Cloud**, the service account needs only the **Google Play Developer API** (also listed as the **Google Play Android Developer API**) enabled in its project — no special Cloud IAM role.

In the **Play Console**, grant the service account:

* **View app information and download bulk reports** (account-level) — or **View app information** (for a single app) — so Amply can read your current products and prices.
* **Manage store presence** — the permission that allows editing pricing for subscriptions and in-app products.

You don't need "Manage orders and subscriptions" — that covers refunds and order management, not price edits.

### Steps

1. In the **Google Cloud Console**, create or select a project, create a **service account**, then create a **JSON key** for it and download the file.
2. In that same project, enable the **Google Play Developer API** (also shown as **Google Play Android Developer API**).
3. In **Play Console → Users and permissions**, choose **Invite new users** and enter the service account's email (the `client_email` from the JSON).
4. Grant the permissions above — account-wide, or for this specific app — then send the invite.
5. **Wait for the permissions to take effect.** This can take up to 24 hours, though it's often much faster.
6. In Amply, open your application's **Integrations** tab and paste the service-account JSON (or upload the file).

## After you connect

Amply checks the credentials when you save. If it accepts them, you're ready to manage prices. If it rejects them, the cause is almost always one of:

* **Apple** — the Key ID / Issuer ID don't match the `.p8`; the `.p8` is incomplete (copy the whole file, including the `BEGIN`/`END` lines); or the key's role is below App Manager.
* **Google** — the service account isn't granted in the Play Console yet, or the permissions haven't propagated (wait, then try again); the Google Play Developer API isn't enabled; or **Manage store presence** wasn't granted. If it still fails after the permissions have propagated, re-save them in the Play Console and try again.

## Keeping credentials safe

* Use a dedicated key / service account for Amply — don't reuse personal credentials.
* Never commit your `.p8` or service-account JSON to source control.
* Revoke and regenerate credentials when a team member leaves or a key may be exposed. Amply stores them encrypted and uses them only to read and update your store pricing.

***

*Last reviewed: June 2026. Store consoles occasionally change their menus — if a label differs, look for the nearest equivalent.*


# Creating a price change

Build a price change in the three-step form, review the computed territory-by-territory prices, apply the draft to the store, and monitor the result.

**Use this when** you're ready to push a new base price, percentage change, or matrix to the store for a specific product. **Don't use this when** you're still deciding on the target numbers — inspect current prices in [Compare](/price-management/comparing-prices) first, or shape the index in [Price indexes](/price-management/price-indexes).

## Prerequisites

* The application is connected to App Store Connect or Google Play Console and its products are visible in Amply.
* The product already has a current price in the base country (for Base-price method) or in every country you want to change (for Percentage method).
* For iOS subscriptions: a decision about whether existing subscribers move to the new price or keep the current one.
* For Android subscriptions: a product with a single base plan — multi-base-plan subscriptions are not supported.

## Step-by-step

{% stepper %}
{% step %}

### Open the form

From **Price Management**, click **New Price Change** in the top right. If the project has more than one application, the active one is preselected — you can switch it at the top of the form.
{% endstep %}

{% step %}

### 1. Product

Pick the product. The list shows subscriptions and in-app purchases for the selected application. For iOS, when the same product identifier exists as both a subscription and an in-app purchase, Amply picks the subscription.

Click **Continue**.
{% endstep %}

{% step %}

### 2. Price details

Fields depend on the pricing method you pick first.

**Pricing method**

* **Base price** — set one price in one country; Amply computes every other territory using a price index.
* **Percentage** — set one ± number; Amply applies it to every territory's current store price.

**If Base price:**

* **Base Country** — the anchor country. Currency is inferred from it.
* **Base Price** — on iOS, the field autocompletes the valid **Apple price points** (tier prices Apple accepts) for the selected country. On Android, any positive number is accepted. If the product already has a store price in the chosen base country, the field pre-fills it.
* **Price Index** — the matrix of multipliers applied for every other territory. In-app purchases default to **Apple Store Matrix (IAP)**; subscriptions default to **App Store**. Change to any system or custom index. See [Price indexes](/price-management/price-indexes).

**If Percentage:**

* **Percentage change** — a signed number (e.g. `-10` for a 10% cut, `+15` for a 15% bump) applied to every current store price.

**Start Date**

When the new prices go live. On iOS, the earliest is tomorrow — Apple rejects same-day updates. On Google Play, the change is accepted immediately but can take several hours to appear on the listing.

**Price change audience** (iOS subscriptions only)

* **New subscribers only** — existing subscribers keep their current price. New subscribers get the new price.
* **All subscribers** — existing subscribers move to the new price on the start date.

Price decreases apply to everyone regardless of this setting.

**Rounding Strategy**

* **No Rounding** — raw computed price is used.
* **Decimal Rounding** — round each price. Pick:
  * **Rounding Direction** — Round to nearest / Round up / Round down
  * **Rounding Profile** — Sharp (`.99`/`99`/`999`), Soft (`.95`/`95`/`950`), Round (`.00`/`00`/`000`), Half (`.50`/`50`/`500`)

Click **Continue**.
{% endstep %}

{% step %}

### 3. Adjust

Review the full per-territory table. Amply computes a new price for every territory the product is available in. Rows are grouped by continent; use **Expand all** / **Collapse all** to navigate.

For each row you can:

* Edit the **new price** inline to override the computed value.
* Untick the row's checkbox to skip that territory.
* Untick a continent header to skip every territory in that region.

A banner at the top lists any territories where the store doesn't support pricing for this product; those are excluded automatically.

Click **Create** at the bottom. The batch saves as a **Draft** and you land on the edit screen.
{% endstep %}
{% endstepper %}

## Drafts

A draft is never sent to the store. You can leave it as long as you like; return to **Price Management**, filter by **Draft**, and open the row to resume editing.

On the **Edit Draft** screen:

* Change individual territory prices and skip flags.
* **Delete** — remove the draft.
* **Apply** — submit the batch to the store. The batch moves to **Pending** and you're taken to the detail view.

Drafts with an expiration date show a reminder in the header; after that date they're cleared automatically.

## Monitoring a batch

Open any non-draft batch from the history. The detail screen shows:

* A status chip in the header: Pending, Sending, Completed, Incomplete, Failed, Cancelled, or Deferred.
* Six counters: Total, Completed, Unchanged, Failed, Deferred, Pending.
* A stacked progress bar colored by entry status.
* A per-territory table: Territory, Original price, New price, Diff (%), Status, Attempts.

While the batch is in flight, the page auto-refreshes every two seconds. Territories move through their own states: Pending → Sending → Completed, Failed, Skipped, Deferred, or Unchanged. An **Unchanged** entry means the computed price equalled the store's current price — no write was made.

### Recovering from failures

When the batch is terminal (Completed, Incomplete, Failed, or Cancelled), two recovery actions may appear in the header:

* **Retry failed (N)** — re-submit every Failed entry. Use this after transient store errors. The batch re-enters Sending.
* **Resume now (N)** — push every Deferred entry immediately. Deferred usually means the store throttled the original attempt; this retries it right away.

While the batch is still in flight (Pending or Sending), the header offers **Cancel batch** instead. Cancelling stops further submissions; already-synced prices stay live.

A fourth action, **Save as custom index**, is shown for any Base-price batch that has entries — it doesn't depend on the batch being terminal. Use it to turn the per-territory matrix into a reusable index. See [Price indexes](/price-management/price-indexes#save-a-batch-as-an-index).

## Common mistakes

* **Base price that isn't a valid Apple tier.** On iOS, the base price must match an Apple price point — use the autocomplete dropdown.
* **Start date set to today.** Apple rejects same-day updates. Set tomorrow or later.
* **All prices Unchanged.** The batch submitted fine but the computed prices matched current store prices exactly. Adjust your base price, percentage, or index.
* **Started the batch before checking availability.** If a product isn't published in a country, that territory is skipped automatically and listed in the banner. Publish it in the store first if you want to include it.

## Related

* [Price management overview](/price-management/price-management) — feature overview and status reference
* [Price indexes](/price-management/price-indexes) — how the matrix used in "Base price" is defined
* [Comparing prices](/price-management/comparing-prices) — preview current prices across products


# Price indexes

A **price index** is a table of per-territory multipliers. When you run a Base-price batch, Amply takes your base-country price and multiplies it by each territory's factor. Indexes come in two flavours — system (maintained by Amply) and custom (yours to edit).

**Use this when** you want to reuse a pricing matrix across products or batches, import one from a spreadsheet, or inspect a system index. **Don't use this when** you only ever run Percentage-method batches — those ignore indexes.

## Prerequisites

* Your plan includes custom price indexes. If the **Price Indexes** entry isn't in the left nav, check your plan.
* The application is connected to App Store Connect or Google Play Console so territory codes resolve correctly in the factor table.

## Where to find it

From the left nav under **Price Management**, click **Price Indexes**. The page heading reads **Custom Price Indexes**. The table lists every index available to the active project — both system and custom — with columns: Name, Countries, Created, Updated.

Two controls above the list:

* **Download template** — exports a blank CSV of every territory for a platform. Pick **All platforms**, **iOS only**, or **Android only**.
* **New index** — choose **From CSV** (upload a filled-in template) or **Blank / manual** (open the editor).

## System vs custom

|               | System                            | Custom   |
| ------------- | --------------------------------- | -------- |
| Maintained by | Amply                             | You      |
| Shown as      | **System** label next to the name | No label |
| Editable      | No — view-only                    | Yes      |
| Deletable     | No                                | Yes      |
| Duplicatable  | Yes — creates a custom copy       | Yes      |

Known system indexes in the dropdown include **Apple Store Matrix (IAP)** and **App Store**. Other system matrices may appear if Amply has provisioned them for your project — check the list.

## Managing custom indexes

### Create from scratch

Click **New index** → **Blank / manual**. The editor opens with:

* **Name** — a label you'll recognize in the dropdown later (for example, `2026 Q2 rollout — premium tier`).
* **Platform** — All platforms, iOS only, or Android only. An iOS-only index is hidden when creating a batch for an Android product, and vice versa.
* **Factor table** — one row per territory, pre-filled at `1.0`. Type a new multiplier: `1.5` means 50% above the base price, `0.8` means 20% below, `1.0` is parity. Every factor must be positive — saving is blocked until the warnings clear.

Click **Create index** to save.

### Create from CSV

1. Click **Download template** and pick the platform. The CSV downloads as `amply-price-index-template-<platform>-<date>.csv`.
2. Fill in the factor column for each territory in a spreadsheet.
3. Back in the list, click **New index** → **From CSV** and upload. Amply parses the file and opens the editor with factors pre-filled — you can still tweak before saving.

### Duplicate

Any row — system or custom — has a **Duplicate** action in its row menu. It opens the editor pre-filled with the original's factors and `(copy)` suffixed on the name. Use this to derive a custom variant of a system index.

### Edit

Click the pencil icon on a custom row. System rows only offer a view-only dialog (the eye icon). The editor shows a **"referenced by N price changes"** banner when the index is currently used — edits to the index don't change prior batches, only future ones.

Click **Save changes** when done.

### Delete

Row menu → **Delete**. The request can fail — for example if the index is still referenced by a batch — and Amply surfaces the error in a toast. Rewrite or remove the offending batches first if that happens.

## Save a batch as an index

Any Base-price batch with entries shows a **Save as custom index** button on its detail view. Amply derives an index from the per-territory prices the batch produced and opens the editor with:

* A suggested name based on the product and date.
* Platform compatibility inferred from the batch.
* The factor table pre-filled.
* Optional warnings if any territory couldn't be derived.

Review, adjust, save. The result is a regular custom index you can use in future batches. Percentage-method batches don't produce an index — there's no per-territory factor matrix to derive.

## How factors are applied

For a Base-price batch using index *I* in base country *C*:

1. Base price *P* is what you entered in the form.
2. For each territory *T* in *I*, the computed price is `P × I[T]`.
3. The result is rounded per your rounding settings (if any).
4. On iOS only, the result is snapped to the nearest valid Apple price point.

Territories missing from *I* are skipped for that batch. The base country's own factor in *I*, if present, is ignored — the base price is used as-is.

## Common mistakes

* **Wrong platform tag.** If an index doesn't show up in the dropdown, check its Platform — an iOS-only index is hidden for Android batches.
* **Template mismatch.** Uploading an Android-only template into an iOS-only index produces rows the other platform doesn't support. Use **All platforms** if you want the index usable everywhere.
* **Non-positive factor.** Zero or negative factors block saving. Keep every row at `1.0` or higher to include it at parity with the base; set lower (e.g. `0.9`) for a discount.
* **Forgetting to update referenced batches.** Editing an index doesn't rewrite batches that already submitted — only future ones using it.

## Related

* [Creating a price change](/price-management/creating-a-price-change) — where indexes are selected
* [Comparing prices](/price-management/comparing-prices) — side-by-side view of current prices across products
* [Price management overview](/price-management/price-management) — when to pick Base price vs Percentage


# Comparing prices

A side-by-side table of current store prices for up to three products across every territory they sell in, with inline editing and a per-product "Create Price Change" shortcut.

**Use this when** you want to see two or three products' current prices next to each other — yearly vs monthly, an in-app purchase vs its subscription equivalent — or when you want to edit prices in a table and save the result as a draft batch. **Don't use this when** you already know the target prices for one product — open [Creating a price change](/price-management/creating-a-price-change) directly.

## Prerequisites

* The application is connected to App Store Connect or Google Play Console and its products are visible in Amply.
* The products you want to compare have at least one current store price.

## Where to find it

From **Price Management**, click **Compare** in the top right. The heading reads **Compare Prices**, breadcrumb **Price Management → Compare**.

## Setting it up

**Application** Pick the application at the top. Compare only works within a single application; it doesn't mix products from different apps.

**Product slots** Add up to three products using the **+** column on the right side of the table. The picker groups products by **Subscriptions** and **In-App Purchases**.

Each slot gets its own column colour so rows stay readable as you scan across.

Every slot header has two icons:

* **Star** — marks the **reference product**. Every other column shows its delta relative to this one. Click the star on another slot to move the reference.
* **Close** — removes the product from the table.

## Reading the table

The row set is the **union** of every country where any selected product has a price. Rows are grouped by continent and sorted alphabetically.

Each slot cell shows the current price (inline-editable) with a small delta label above it. The colour follows the sign of the delta:

* Positive (this slot's price is **above** the reference) — green.
* Negative (this slot's price is **below** the reference) — red.
* Near zero (within ±0.05%) — neutral.

Edits are local to Compare. They seed the draft when you save, but they don't change anything in the store on their own.

## The "Compare by" toggle

Above the table, pick how the delta is computed:

* **%** — raw percentage difference between each slot's price and the reference's price, with no period normalization.
* **/wk** — normalize each price to its weekly equivalent first, then compute the percentage delta.
* **/mo** — normalize each price to its monthly equivalent.
* **/yr** — normalize each price to its yearly equivalent.

The `/wk`, `/mo`, `/yr` options are only available when every slot is a subscription — one-time products don't have a billing period to normalize over. Use these to compare, say, a monthly and an annual sub on equal terms.

## Turning a comparison into a draft

Each product column has its own **Create Price Change** button. Clicking it opens a two-step dialog for that product:

{% stepper %}
{% step %}

### Settings

* **Start date** — same constraints as a real batch (tomorrow or later on iOS).
* **Audience** (iOS subscriptions only) — New subscribers only / All subscribers.
* **Rounding Strategy** — No Rounding or Decimal Rounding.
* **Rounding Direction** (if Decimal) — Round to nearest / Round up / Round down.
* **Rounding Profile** (if Decimal) — Sharp / Soft / Round / Half.

Click **Next** to review.
{% endstep %}

{% step %}

### Review

The dialog applies the selected rounding to every current price you see in the Compare table (plus any inline edits you made) and shows the final per-territory values.

Click **Create draft** to save.
{% endstep %}
{% endstepper %}

The button creates a **Base-price** batch anchored in the **United States** — the US row supplies the base price, every other territory gets its exact value from the Compare table. No price index is used; the territory list is the set of literal prices from the table, rounded per your dialog settings. Amply confirms with a toast that includes a **review and apply** link to the new draft — click it to open the **Edit Draft** screen, where you can still edit individual territory prices or skip countries before applying.

If you want a batch driven by a price index instead of by literal per-territory prices, start from **New Price Change** on the history screen — see [Creating a price change](/price-management/creating-a-price-change).

## Tips

* **Pick the reference thoughtfully.** Every delta is relative to it — switching the reference re-colours the entire table.
* **Yearly vs monthly.** Drop a yearly and a monthly sub into two slots, switch to `/mo`, and you'll see whether your yearly is priced above or below twelve monthly periods in every country.
* **Inline edits don't modify the store.** Until you click **Create draft**, nothing is persisted.
* **Stale-looking prices.** Compare reads the prices Amply knows about. If a batch recently completed, give it a moment to propagate.

## Related

* [Creating a price change](/price-management/creating-a-price-change) — the full three-step form with index selection
* [Price indexes](/price-management/price-indexes) — how a base-price + index batch computes territory prices
* [Price management overview](/price-management/price-management) — where this screen lives


# Installation

How to add Amply to your mobile app. One dependency, three platforms — pick the tab that matches your stack.

Installing Amply means adding a single package to your build config, then calling the SDK from your app's entry point. This page only covers the dependency step. Go to the platform-specific Quickstart once the package resolves.

{% hint style="info" %}
**Two ways to integrate Amply.** This guide is the **by-hand** path. The other is [**AI-assisted integration**](/getting-started/ai-assisted-integration): connect the Amply MCP and the `amply-integration` skill to your AI coding assistant (Claude Code or Codex CLI) and it installs the SDK, forwards the events Amply needs for targeting, and hands you a reviewable diff — the same result as the steps below, in one pass.
{% endhint %}

## Current versions

Other pages in this guide reference "the current version" — the table below is the single source of truth. When a new SDK ships, only this page needs updating.

| Package           | Version  |
| ----------------- | -------- |
| iOS / Android SDK | `0.10.1` |
| React Native SDK  | `0.10.1` |

## Requirements

| Platform     | Minimum OS             | Other                                               |
| ------------ | ---------------------- | --------------------------------------------------- |
| iOS SDK      | iOS 14.0               | Xcode 15+, Swift 5.9+                               |
| Android SDK  | Android 5.0 (API 21)   | Kotlin 1.9+, AGP 8.0+                               |
| React Native | RN 0.79+, Expo SDK 54+ | iOS 14+ / Android API 24+, New Architecture enabled |

## Credentials

You need three values from the Amply dashboard before the SDK will run:

* `appId` — your app's identifier (e.g., `com.acme.app`)
* `apiKeyPublic` — short key used to identify your app
* `apiKeySecret` — longer key used to sign requests

Open the Amply dashboard, go to **Settings → API Keys**, and copy them into the config shown in the Quickstarts.

## Install

{% tabs %}
{% tab title="iOS (Swift)" %}

### CocoaPods

Add to your `Podfile`:

```ruby
pod 'AmplySDK', '~> 0.10.1'
```

Then install:

```bash
pod install
```

Open the generated `.xcworkspace` in Xcode.

### Swift Package Manager

In Xcode: **File → Add Package Dependencies…** and enter:

```
https://github.com/amply-tools/amply-sdk-ios
```

Pick the version rule (recommended: *Up to Next Major Version* from `0.10.1`) and add `AmplySDK` to your target.

Or declare it in `Package.swift`:

```swift
dependencies: [
    .package(url: "https://github.com/amply-tools/amply-sdk-ios", from: "0.10.1")
]
```

### Info.plist

No mandatory entries. If you plan to read the advertising identifier (IDFA) for attribution, add `NSUserTrackingUsageDescription` to `Info.plist` and call the App Tracking Transparency prompt yourself — the SDK will pick up the resulting status.

Continue with the [iOS Quickstart](/developer-guide/quickstart-ios) to wire the SDK into your `AppDelegate`.
{% endtab %}

{% tab title="Android (Kotlin)" %}

### Gradle (Kotlin DSL)

Add to your app module's `build.gradle.kts`:

```kotlin
dependencies {
    implementation("tools.amply:sdk-android:0.10.1")
}
```

### Gradle (Groovy)

```groovy
dependencies {
    implementation 'tools.amply:sdk-android:0.10.1'
}
```

### Repositories

The SDK ships to Maven Central. If your `settings.gradle.kts` already lists `mavenCentral()` (the default for new Android projects) you don't need to change anything:

```kotlin
dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
    }
}
```

### Permissions

No permissions are required to initialize the SDK. `INTERNET` (already granted by default) is used to send events. If you want to collect the Google advertising ID for attribution, add:

```xml
<uses-permission android:name="com.google.android.gms.permission.AD_ID" />
```

Continue with the [Android Quickstart](/developer-guide/quickstart-android) to wire the SDK into your `Application` class.
{% endtab %}

{% tab title="React Native" %}

### npm / yarn

```bash
yarn add @amplytools/react-native-amply-sdk
```

or

```bash
npm install @amplytools/react-native-amply-sdk
```

### Expo

Amply ships an Expo config plugin. Add it to `app.json`:

```json
{
  "expo": {
    "plugins": ["@amplytools/react-native-amply-sdk"]
  }
}
```

Then run a prebuild so the native modules link:

```bash
npx expo prebuild
```

### Bare React Native

Autolinking picks up the module after `yarn install`. On iOS you also need to install the pod:

```bash
cd ios && pod install
```

No extra Gradle config is required on Android.

See the [Requirements](#requirements) table above for minimum versions.

Continue with the [React Native Quickstart](/developer-guide/quickstart-react-native) to initialize the SDK in your root component.
{% endtab %}
{% endtabs %}

## Related

* [iOS Quickstart](/developer-guide/quickstart-ios) — first tracked event from a Swift app
* [Android Quickstart](/developer-guide/quickstart-android) — first tracked event from a Kotlin app
* [React Native Quickstart](/developer-guide/quickstart-react-native) — first tracked event from TypeScript
* [iOS integration](/developer-guide/ios-integration) — deep links, session lifecycle, custom properties
* [Android integration](/developer-guide/android-integration) — deep links, custom properties, log levels
* [React Native integration](/developer-guide/react-native-integration) — listeners, snapshots, Expo specifics


# Quickstart — iOS

Install the Amply iOS SDK, initialize it with your API keys, and send your first event. You'll end with a tracked event visible in the Amply dashboard.

This tutorial takes about ten minutes. It uses Swift and UIKit's `AppDelegate`. The same pattern works in SwiftUI — call the same initializer from the `App` struct's `init()`.

**Prerequisites**

* An iOS 14.0+ deployment target — see [Installation](/developer-guide/installation) for the full requirements matrix
* An Amply dashboard account with `appId`, `apiKeyPublic`, and `apiKeySecret` (Settings → API Keys)
* CocoaPods or Swift Package Manager set up in your project

**You'll end with**

* The SDK initialized on app launch
* One custom event tracked from your code
* That event showing up in the Amply dashboard's event log

## 1. Add the SDK

Follow [Installation → iOS](/developer-guide/installation) to add the `AmplySDK` pod or Swift package. Come back here once `import AmplySDK` resolves cleanly.

## 2. Initialize on launch

Initialize Amply in `AppDelegate.application(_:didFinishLaunchingWithOptions:)`. Constructing `Amply(config:)` starts the SDK — there is no separate `initialize()` call.

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

```swift
import UIKit
import AmplySDK

@main
class AppDelegate: UIResponder, UIApplicationDelegate {

    static var amply: Amply?

    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        let config = AmplyConfig(
            appId: "com.acme.app",
            apiKeyPublic: "YOUR_PUBLIC_KEY",
            apiKeySecret: "YOUR_SECRET_KEY",
            defaultConfig: nil
        )

        AppDelegate.amply = Amply(config: config)
        AppDelegate.amply?.setLogLevel(level: "debug")

        return true
    }
}
```

{% endcode %}

`AmplyConfig` takes three required fields — `appId`, `apiKeyPublic`, `apiKeySecret` — plus an optional `defaultConfig` used for preloaded campaign content. Leave it `nil` for now.

`setLogLevel(level: "debug")` prints SDK activity to the Xcode console while you're wiring things up. Remove or switch to `"warn"` before shipping.

{% hint style="info" %}
**SwiftUI?** Put the same `AmplyConfig` + `Amply(config:)` in your `App` struct's `init()` and store the instance on an `@StateObject` or singleton of your choice.
{% endhint %}

## 3. Track your first event

From anywhere in your app — a button handler, a view controller's `viewDidLoad`, a coordinator — call `track`:

```swift
AppDelegate.amply?.track(event: "Signup", properties: ["plan": "pro"])
```

`track(event:properties:)` takes an event name and an optional `[String: Any]` map of properties. Event names should be short and consistent (e.g., `Signup`, `Purchase`, `TrialStarted`). Properties go on the event for segmentation in the dashboard.

## 4. Manage the session lifecycle

iOS doesn't emit the same lifecycle callbacks as Android, so Amply exposes three session methods you call yourself. Wire them to the standard `UIApplicationDelegate` hooks so sessions bookend real user activity.

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

```swift
func applicationDidBecomeActive(_ application: UIApplication) {
    AppDelegate.amply?.resumeSession()
}

func applicationDidEnterBackground(_ application: UIApplication) {
    AppDelegate.amply?.pauseSession()
}

func applicationWillTerminate(_ application: UIApplication) {
    AppDelegate.amply?.stopSession()
}
```

{% endcode %}

* `resumeSession()` — call when the app becomes active again
* `pauseSession()` — call when the app enters background
* `stopSession()` — call on terminate, or when you want to force-close the current session

Skipping these won't crash the SDK, but session duration and session-scoped custom properties will be inaccurate.

{% hint style="warning" %}
Android auto-manages sessions via the process lifecycle. iOS does not — this step is required on iOS.
{% endhint %}

## 5. Verify in the dashboard

1. Run the app on a simulator or device.
2. Trigger the code path that calls `track(event: "Signup", ...)`.
3. Open the Amply dashboard and go to **Events** (or the real-time event log for your app).
4. You should see an entry for `signup` with the `plan: pro` property within a few seconds.

If the event doesn't appear, check:

* The Xcode console (log level `"debug"`) for SDK errors
* That `appId`, `apiKeyPublic`, and `apiKeySecret` match your dashboard app exactly
* That the device has network connectivity — events are queued and retried, but the first event needs a live connection to prove the pipeline works

## What's next

* [iOS integration](/developer-guide/ios-integration) — deep link handling, custom properties, user identification
* [Concepts → Sessions and events](/concepts/sessions-and-events) — how events, properties, and datasets fit together
* [Recipes — entry-based onboarding routing](/recipes/entry-based-onboarding-routing) — ready-made pattern for signup and onboarding flows

## Related

* [Installation](/developer-guide/installation) — pod / SPM setup details
* [Android Quickstart](/developer-guide/quickstart-android) — same flow on Android


# Quickstart — Android

Install the Amply Android SDK, initialize it with your API keys, and send your first event. You'll end with a tracked event visible in the Amply dashboard.

This tutorial takes about ten minutes. It uses Kotlin and initializes the SDK in your `Application` class, which is the recommended entry point because it fires before any `Activity` starts.

**Prerequisites**

* A minSdk of 21 (Android 5.0) or higher — see [Installation](/developer-guide/installation) for the full requirements matrix
* An Amply dashboard account with `appId`, `apiKeyPublic`, and `apiKeySecret` (Settings → API Keys)
* Gradle configured to resolve from `mavenCentral()`

**You'll end with**

* The SDK initialized on app startup
* One custom event tracked from your code
* That event showing up in the Amply dashboard's event log

## 1. Add the SDK

Follow [Installation → Android](/developer-guide/installation) to add `tools.amply:sdk-android` to your app module's dependencies (see [Installation — Current versions](/developer-guide/installation#current-versions) for the version to pin). Come back here once your build resolves.

## 2. Create (or open) your Application class

If your project already has a custom `Application`, open it. If not, create one next to your `MainActivity`:

{% code title="app/src/main/java/com/acme/app/AmplyApp.kt" %}

```kotlin
package com.acme.app

import android.app.Application
import tools.amply.sdk.Amply
import tools.amply.sdk.config.amplyConfig

class AmplyApp : Application() {

    lateinit var amply: Amply
        private set

    override fun onCreate() {
        super.onCreate()

        val config = amplyConfig {
            api {
                appId = "com.acme.app"
                apiKeyPublic = "YOUR_PUBLIC_KEY"
                apiKeySecret = "YOUR_SECRET_KEY"
            }
        }

        amply = Amply(config, this)
    }
}
```

{% endcode %}

Register it in `AndroidManifest.xml`:

```xml
<application
    android:name=".AmplyApp"
    ... >
```

Constructing `Amply(config, this)` starts the SDK. There's no separate `initialize()` call. The `Application` instance is required so the SDK can observe the process lifecycle automatically (see step 4).

{% hint style="info" %}
The `amplyConfig { api { ... } }` DSL is the recommended way to build `AmplyConfig` — it's declarative and keeps credentials grouped.
{% endhint %}

## 3. Track your first event

From anywhere with access to the `Application` (or a DI-provided `Amply` instance), call `track`:

```kotlin
val amply = (application as AmplyApp).amply
amply.track(event = "Signup", properties = mapOf("plan" to "pro"))
```

`track(event, properties)` takes an event name and an optional `Map<String, Any>` of properties. Names should be short and consistent (e.g., `Signup`, `Purchase`, `TrialStarted`). Supported property value types: `String`, `Int`, `Long`, `Float`, `Double`, `Boolean`, `DateTimeValue`.

From a `ViewModel` or `Activity`, inject the SDK instance rather than reaching through `application` — the approach above is just the shortest path to a first event.

## 4. Session lifecycle (nothing to do)

On Android the SDK observes `ProcessLifecycleOwner` and manages sessions automatically:

* A session starts the first time your process becomes foreground.
* It pauses when the app goes to background and resumes when the user returns.
* It ends after the configured inactivity timeout.

You don't call `pauseSession()`, `resumeSession()`, or `stopSession()` on Android. (Those methods exist on iOS because UIKit doesn't give the SDK a process-level lifecycle hook.)

## 5. Turn on logs while you verify

Before shipping, lower the log level. While wiring things up, turn it up:

```kotlin
amply.setLogLevel("debug")
```

SDK output lands in Logcat under tags starting with `Amply`. Use the Logcat filter `tag:Amply` to see only SDK messages.

Valid levels: `"none"`, `"error"`, `"warn"`, `"info"`, `"debug"`.

## 6. Verify in the dashboard

1. Run the app on an emulator or device.
2. Trigger the code path that calls `track(event = "Signup", ...)`.
3. Open the Amply dashboard and go to **Events** (or the real-time event log for your app).
4. You should see a `signup` entry with `plan: pro` within a few seconds.

If nothing shows up:

* Filter Logcat on `tag:Amply` and look for error lines
* Confirm the values in `amplyConfig { api { ... } }` match the dashboard Settings → API Keys page exactly
* Confirm the device has internet — the first event needs a successful round-trip to prove the pipeline works

## What's next

* [Android integration](/developer-guide/android-integration) — deep links, custom properties, user IDs, ProGuard notes
* [Concepts → Sessions and events](/concepts/sessions-and-events) — how events, properties, and datasets fit together
* [Recipes — entry-based onboarding routing](/recipes/entry-based-onboarding-routing) — ready-made pattern for signup and onboarding flows

## Related

* [Installation](/developer-guide/installation) — Gradle coordinates, minimum versions, permissions
* [iOS Quickstart](/developer-guide/quickstart-ios) — same flow on iOS (note: iOS requires manual session calls)


# Quickstart — React Native

Install the Amply React Native SDK, initialize it with your API keys, and send your first event. You'll end with a tracked event visible in the Amply dashboard.

This tutorial takes about ten minutes. It uses TypeScript and works the same way for bare React Native and Expo projects. The SDK runs on the native side — `Amply.initialize()` is async because it hands off to iOS and Android at startup.

**Prerequisites**

* React Native 0.79+ with the New Architecture enabled (or Expo SDK 54+) — see [Installation](/developer-guide/installation) for the full requirements matrix
* Node 18+ and either npm or yarn
* An Amply dashboard account with `appId`, `apiKeyPublic`, and `apiKeySecret` (Settings → API Keys)

**You'll end with**

* The SDK initialized when your app mounts
* One custom event tracked from TypeScript
* That event showing up in the Amply dashboard's event log

## 1. Add the SDK

Follow [Installation → React Native](/developer-guide/installation) to add `@amplytools/react-native-amply-sdk`. For Expo apps, make sure you've added the config plugin and run `npx expo prebuild`. For bare RN, run `pod install` in `ios/`.

Come back here once `import Amply from '@amplytools/react-native-amply-sdk'` resolves.

## 2. Initialize on app start

Initialize Amply once, as early as possible — typically in your root `App` component, inside a `useEffect` that runs on mount. `initialize()` is async and returns a `Promise<void>`; await it (or chain `.then`) before calling any other SDK method.

{% code title="App.tsx" %}

```typescript
import React, {useEffect, useState} from 'react';
import {Text, View} from 'react-native';
import Amply from '@amplytools/react-native-amply-sdk';

export default function App(): React.JSX.Element {
  const [ready, setReady] = useState(false);

  useEffect(() => {
    Amply.initialize({
      appId: 'com.acme.app',
      apiKeyPublic: 'YOUR_PUBLIC_KEY',
      apiKeySecret: 'YOUR_SECRET_KEY',
      logLevel: 'debug',
    })
      .then(() => setReady(true))
      .catch(error => console.error('Amply init failed', error));
  }, []);

  return (
    <View>
      <Text>{ready ? 'Amply ready' : 'Initializing…'}</Text>
    </View>
  );
}
```

{% endcode %}

The `AmplyInitializationConfig` fields you'll use here:

| Field            | Type                                               | Required | Notes                                                     |
| ---------------- | -------------------------------------------------- | -------- | --------------------------------------------------------- |
| `appId`          | `string`                                           | yes      | Your app identifier                                       |
| `apiKeyPublic`   | `string`                                           | yes      | Public key from the dashboard                             |
| `apiKeySecret`   | `string`                                           | no       | Secret key from the dashboard                             |
| `configBaseUrl`  | `string`                                           | no       | Point campaign config at another stack (development only) |
| `backendBaseUrl` | `string`                                           | no       | Point event delivery at another stack (development only)  |
| `logLevel`       | `'none' \| 'error' \| 'warn' \| 'info' \| 'debug'` | no       | Print SDK activity to Metro console                       |

`logLevel: 'debug'` is the quickest way to see what the SDK is doing in Metro. Switch to `'warn'` or `'none'` before shipping.

Campaign config and event delivery are served from **different hosts**, so pointing the app at a staging or local stack takes both `configBaseUrl` and `backendBaseUrl`. Setting only one leaves the other half of the traffic on production.

{% hint style="info" %}
`Amply.isInitialized()` is synchronous and returns `true` after the promise resolves. Use it from screens that mount before init completes.
{% endhint %}

## 3. Track your first event

Call `Amply.track()` from any component, hook, or handler. It takes a payload object with a `name` and an optional `properties` map.

```typescript
import Amply from '@amplytools/react-native-amply-sdk';

async function onSignupPress() {
  await Amply.track({
    name: 'Signup',
    properties: {plan: 'pro'},
  });
}
```

`track()` returns a `Promise<void>`. You can `await` it when you want to be sure the event was handed to the native side (e.g., before navigating), or fire-and-forget for UI events.

Property values must be strings, numbers, or booleans. Keep event names short and consistent — the dashboard segments by event name, so a typo becomes a different event.

## 4. Identify the user (optional but recommended)

Once you know who the user is — after signup, login, or restoring a session — tell Amply:

```typescript
Amply.setUserId('user-123');
Amply.setCustomProperties({plan: 'pro', onboarded: true});
```

`setUserId()` is fire-and-forget (no promise). Pass `null` to clear the user ID on sign-out. `setCustomProperties()` accepts a `Record<string, string | number | boolean>` and merges into whatever is already stored.

## 5. Verify in the dashboard

1. Run the app on an iOS simulator, Android emulator, or real device (`yarn ios` / `yarn android`, or `npx expo run:ios` / `run:android`).
2. Trigger the code path that calls `Amply.track({name: 'Signup', ...})`.
3. Open the Amply dashboard and go to **Events**.
4. Watch for a `signup` entry with `plan: pro` — it should arrive within a few seconds.

If you don't see it:

* Check the Metro console for `[Amply]` log lines (visible when `logLevel: 'debug'` is set)
* Confirm `appId` / `apiKeyPublic` / `apiKeySecret` match the dashboard Settings → API Keys page exactly
* Confirm the device has network connectivity — the first event needs a successful round-trip to prove the pipeline works
* On Expo, confirm you re-ran `npx expo prebuild` after adding the plugin

## What's next

* [React Native integration](/developer-guide/react-native-integration) — deep link listeners, system events, snapshots, hooks
* [Concepts → Sessions and events](/concepts/sessions-and-events) — how events, properties, and datasets fit together
* [Recipes — entry-based onboarding routing](/recipes/entry-based-onboarding-routing) — ready-made pattern for signup and onboarding flows

## Related

* [Installation](/developer-guide/installation) — npm package, Expo plugin, version requirements
* [iOS Quickstart](/developer-guide/quickstart-ios) — native Swift flow
* [Android Quickstart](/developer-guide/quickstart-android) — native Kotlin flow


# iOS integration

In plain English: this page is a collection of how-tos for the Amply iOS SDK beyond "just get it running." It covers session control, deeplink scheme setup, app lifecycle bridging, and the preflight pattern for asking permissions the right way. A PM can skim the section headings to know which knobs exist; an engineer copies the Swift snippets.

**Use this when** you already have the iOS SDK initialized and you need to wire up a specific flow (deeplinks, lifecycle, permissions). **Don't use this when** you haven't finished the Quickstart yet — start there.

## Before you start

* iOS SDK initialized in `AppDelegate` (see [iOS quickstart](/developer-guide/quickstart-ios)).
* A strong reference held to your `Amply` instance and any listener adapters on your own side, so they stay alive for the lifetime of the app.

## Managing the session manually

On iOS the SDK does not auto-track app foreground/background. You call the lifecycle methods yourself. This is different from Android, where lifecycle is automatic.

**Signatures** (from `Amply`):

```swift
func pauseSession()
func resumeSession()
func stopSession()
```

Wire them up in your `AppDelegate`:

```swift
// AppDelegate.swift
import AmplySDK

func applicationDidBecomeActive(_ application: UIApplication) {
    AppDelegate.amply?.resumeSession()
}

func applicationDidEnterBackground(_ application: UIApplication) {
    AppDelegate.amply?.pauseSession()
}

func applicationWillTerminate(_ application: UIApplication) {
    AppDelegate.amply?.stopSession()
}
```

If you use `UISceneDelegate`, bridge the same calls from `sceneDidBecomeActive(_:)` and `sceneDidEnterBackground(_:)` instead.

If you need to distinguish "brief interruption" (incoming call, control center, app switcher preview) from "user left the app," call `pauseSession()` from `applicationWillResignActive(_:)` instead of `applicationDidEnterBackground(_:)`. The trade-off: session timing is more sensitive to transient interruptions, but you capture the moment attention leaves the screen.

### When to pause vs. stop

* `pauseSession()` — app went to background. Session can resume when the user returns.
* `resumeSession()` — app returned to foreground before the session timeout.
* `stopSession()` — force-end the current session (for example, user logs out).

## Registering your deeplink scheme

The SDK will deliver campaign deeplinks to your app, but the scheme still needs to be declared in `Info.plist` so iOS routes URLs to you.

```xml
<!-- Info.plist -->
<key>CFBundleURLTypes</key>
<array>
    <dict>
        <key>CFBundleTypeRole</key>
        <string>Editor</string>
        <key>CFBundleURLName</key>
        <string>com.example.yourapp</string>
        <key>CFBundleURLSchemes</key>
        <array>
            <string>yourapp</string>
        </array>
    </dict>
</array>
```

Use the same scheme when you author deeplink URLs in the Amply dashboard (for example `yourapp://promo/summer`).

## Receiving campaign deeplinks

Register a listener after SDK init. The callback returns a `Bool` — `true` if you handled the URL, `false` to let the system default handler try.

**Signature:**

```swift
func registerDeepLinkListener(listener: DeepLinkListener) -> ListenerToken
// DeepLinkListener.onDeepLink(url: String, info: [String: Any]) -> Bool
```

```swift
// DeepLinkAdapter.swift
import AmplySDK

final class DeepLinkAdapter: NSObject, DeepLinkListener {
    func onDeepLink(url: String, info: [String: Any]) -> Bool {
        guard let parsed = URL(string: url) else { return false }
        if parsed.scheme == "yourapp", parsed.host == "promo" {
            let promoId = parsed.lastPathComponent
            Router.shared.showPromo(id: promoId)
            return true
        }
        return false
    }
}

// AppDelegate.swift
private let deepLinkAdapter = DeepLinkAdapter()
private var deepLinkToken: ListenerToken?
// Keep the adapter retained on the AppDelegate, not as a local — and keep the token with it.
deepLinkToken = amply?.registerDeepLinkListener(listener: deepLinkAdapter)
```

{% hint style="warning" %}
**Store the token.** Registering returns a `ListenerToken`, and that token — not the adapter object — is what detaches the listener later via `removeDeepLinkListener(token:)`.

A listener registered once at launch and kept for the life of the app never needs detaching, so storing the token costs nothing. Anything shorter-lived does need it: a listener left attached after its owner is gone keeps that owner alive and the SDK goes on calling it.
{% endhint %}

## Universal Links

Amply deeplinks are delivered through the SDK's listener regardless of scheme format. If you also want to handle Universal Links (`https://yourapp.com/...`) tapped from outside a campaign, use the standard iOS entry point:

```swift
// AppDelegate.swift
func application(
    _ application: UIApplication,
    continue userActivity: NSUserActivity,
    restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
) -> Bool {
    guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
          let url = userActivity.webpageURL else { return false }
    Router.shared.handle(url: url)
    return true
}
```

Track the hit through Amply if it matters for targeting:

```swift
AppDelegate.amply?.track(
    event: "UniversalLinkOpened",
    properties: ["url": url.absoluteString]
)
```

## Push notification preflight popup

Asking iOS for push permission directly means a single "Allow / Don't Allow" dialog with no second chance. The common pattern is to show a soft-preflight popup via an Amply campaign first, then only call `UNUserNotificationCenter` for users who tap "Sure."

High-level flow:

1. Track an event at a good moment, for example `OnboardingCompleted`.
2. In the Amply dashboard, target a popup campaign at that event.
3. The popup's primary button opens a deeplink like `yourapp://permissions/push`.
4. Your deeplink handler calls `requestAuthorization`.

```swift
// DeepLinkAdapter.swift (extended)
func onDeepLink(url: String, info: [String: Any]) -> Bool {
    guard let parsed = URL(string: url) else { return false }
    if parsed.host == "permissions", parsed.lastPathComponent == "push" {
        UNUserNotificationCenter.current().requestAuthorization(
            options: [.alert, .badge, .sound]
        ) { granted, _ in
            AppDelegate.amply?.track(
                event: granted ? "PushPermissionGranted" : "PushPermissionDenied"
            )
        }
        return true
    }
    return false
}
```

For the full recipe including copy tips and retry cadence, see [Soft push permission](/recipes/soft-push-permission).

## Listening to SDK system events

Useful during development to see when config loads, when sessions start, and which campaigns evaluate.

**Signature:**

```swift
func setSystemEventsListener(listener: SystemEventsListener) -> ListenerToken
// SystemEventsListener.onEvent(event: EventInterface)
```

```swift
final class SystemEventsAdapter: NSObject, SystemEventsListener {
    func onEvent(event: EventInterface) {
        print("[Amply] \(event.name) \(event.properties)")
    }
}

private let systemEventsAdapter = SystemEventsAdapter()
private var systemEventsToken: ListenerToken?

systemEventsToken = amply?.setSystemEventsListener(listener: systemEventsAdapter)
```

Hold the adapter on the `AppDelegate` or another long-lived owner, and hold the token alongside it — `clearSystemEventsListener(token:)` takes the token, not the adapter.

## Log level

```swift
amply?.setLogLevel(level: "debug")   // 'none' | 'error' | 'warn' | 'info' | 'debug'
```

Leave it at `warn` or lower in production builds.

## ATT and IDFA

The SDK reads IDFA when App Tracking Transparency has been granted. Prompting for ATT is your app's responsibility:

```swift
import AppTrackingTransparency

ATTrackingManager.requestTrackingAuthorization { _ in
    // SDK will pick up IDFA on the next dataset read.
}
```

Request ATT on a meaningful screen, not immediately on launch — Apple rejects apps that prompt before context is established.

## Related

* [iOS quickstart](/developer-guide/quickstart-ios) — initial setup, not covered here.
* [Tracking events](/developer-guide/tracking-events) — payload shapes and common patterns.
* [Handling deeplinks](/developer-guide/handling-deeplinks) — cross-platform deeplink routing patterns.
* [Soft push permission](/recipes/soft-push-permission) — full preflight recipe.
* [SDK reference: iOS](/reference/sdk-ios) — full method list.


# Android integration

In plain English: this page is a collection of how-tos for the Amply Android SDK beyond "just get it running." It covers where to initialize, deeplink intent filters, lifecycle (the SDK handles this for you), and the permissions that affect what data the SDK can collect. A PM can skim headings to understand which knobs exist; an engineer copies the Kotlin snippets.

**Use this when** the Android SDK is initialized and you need to wire up a specific flow. **Don't use this when** you haven't finished the Quickstart yet — start there.

## Before you start

* A minSdk of 21 (Android 5.0) or higher — see [Installation](/developer-guide/installation) for the full requirements matrix.
* An `Application` subclass — create one if you don't already have it.

## Initialize in your Application class

The SDK needs the `Application` instance because it hooks Android's lifecycle to auto-manage sessions. This is the main difference from iOS, where you call `pauseSession` / `resumeSession` yourself.

**Signatures** (from `Amply`):

```kotlin
// Android
Amply(config: AmplyConfig, application: Application)
```

```kotlin
// MyApplication.kt
import android.app.Application
import tools.amply.sdk.Amply
import tools.amply.sdk.config.amplyConfig

class MyApplication : Application() {

    lateinit var amply: Amply
        private set

    override fun onCreate() {
        super.onCreate()

        val config = amplyConfig {
            api {
                appId = "your.app.id"
                apiKeyPublic = "your_public_key"
                apiKeySecret = "your_secret_key"
            }
        }

        amply = Amply(config, this)
    }
}
```

Register it in `AndroidManifest.xml`:

```xml
<application
    android:name=".MyApplication"
    android:allowBackup="true"
    android:icon="@mipmap/ic_launcher"
    android:theme="@style/Theme.App">
    <!-- activities -->
</application>
```

### Accessing the instance

Expose it through the `Application` context:

```kotlin
// Anywhere with a Context
val amply = (context.applicationContext as MyApplication).amply
amply.track("ScreenViewed")
```

Consider wrapping this in a small helper (`App.amply(context)`) so the cast doesn't leak through your codebase.

## Lifecycle is automatic

Once the SDK has your `Application`, it listens to process lifecycle events and manages sessions for you. You do **not** need to call `pauseSession`, `resumeSession`, or `stopSession` in `onPause`/`onStop` — those methods don't exist on the Android SDK.

What this means in practice:

* Session starts the first time your app enters the foreground after init.
* Session pauses shortly after the last Activity leaves the foreground.
* A new session begins when the user returns after the timeout.

## Registering deeplink intent filters

The SDK delivers campaign deeplinks to your listener regardless of scheme, but Android still needs to know how to route URLs from outside your process (notification taps, browser handoff). Declare an intent filter on the Activity that should receive them.

```xml
<!-- AndroidManifest.xml -->
<activity
    android:name=".MainActivity"
    android:launchMode="singleTask"
    android:exported="true">
    <intent-filter android:autoVerify="false">
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data android:scheme="yourapp" android:host="promo" />
    </intent-filter>
</activity>
```

Use the same scheme (`yourapp://`) when authoring campaign deeplink URLs in the Amply dashboard.

## Receiving campaign deeplinks

Register a listener after SDK init. Return `true` if you handled the URL, `false` to fall through.

**Signature:**

```kotlin
fun registerDeepLinkListener(listener: DeepLinkListener): ListenerToken
// DeepLinkListener.onDeepLink(url: String, info: Map<String, Any>): Boolean
```

`DeepLinkListener` is a plain interface, not a `fun interface`, so pass an object — a lambda will not compile.

```kotlin
// MyApplication.onCreate()
val deepLinkToken = amply.registerDeepLinkListener(object : DeepLinkListener {
    override fun onDeepLink(url: String, info: Map<String, Any>): Boolean = when {
        url.startsWith("yourapp://promo/") -> {
            val promoId = url.removePrefix("yourapp://promo/")
            Router.openPromo(this@MyApplication, promoId)
            true
        }
        else -> false
    }
})
```

Keep `deepLinkToken` — it is what detaches the listener via `removeDeepLinkListener(token)`. A listener registered in `Application.onCreate` and kept for the life of the process never needs detaching; one owned by an `Activity` or a feature module does.

### Handling external deeplinks

When the user taps a URL in another app, Android delivers it as an `Intent` to your declared Activity. Amply's listener handles campaign-triggered deeplinks; external ones still go through standard `Intent` handling:

```kotlin
// MainActivity.kt
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    intent?.data?.let { uri -> handleUri(uri) }
}

override fun onNewIntent(intent: Intent) {
    super.onNewIntent(intent)
    intent.data?.let { uri -> handleUri(uri) }
}

private fun handleUri(uri: Uri) {
    // Your navigation
}
```

## Permissions

### Advertising ID (optional)

To collect the Google Advertising ID, add the dependency:

```kotlin
// app/build.gradle.kts
dependencies {
    implementation("com.google.android.gms:play-services-ads-identifier:18.0.1")
}
```

Starting with Android 13, the Play Services library also requires a manifest permission, which is added automatically by the library's manifest merger. No further code is needed — the SDK picks up the ID when available.

### Internet

The SDK requires `android.permission.INTERNET`, which is included by default in apps targeting recent SDK versions. Verify it's not stripped by a restrictive manifest.

## Listening to SDK system events

Useful during development to see when config loads, when sessions start, and which campaigns evaluate.

**Signature:**

```kotlin
fun setSystemEventsListener(listener: SystemEventsListener): ListenerToken
// SystemEventsListener.onEvent(event: EventInterface)
```

```kotlin
val systemEventsToken = amply.setSystemEventsListener(object : SystemEventsListener {
    override fun onEvent(event: EventInterface) {
        Log.d("Amply", "${event.name} ${event.properties}")
    }
})
```

`clearSystemEventsListener(token)` takes the token, not the listener.

## Log level

```kotlin
amply.setLogLevel("debug")   // 'none' | 'error' | 'warn' | 'info' | 'debug'
```

Leave it at `warn` or lower in production builds.

## Push notification preflight popup

Asking Android 13+ for `POST_NOTIFICATIONS` directly means a single dialog. The pattern most apps use is a soft-preflight popup via an Amply campaign, which routes to a deeplink that triggers the system permission.

Handle it as a branch **inside the listener you already registered above** — do not register a second one, since registration appends rather than replaces.

```kotlin
override fun onDeepLink(url: String, info: Map<String, Any>): Boolean = when {
    url == "yourapp://permissions/push" -> {
        requestPostNotificationsPermission()
        true
    }
    // ...your other routes
    else -> false
}
```

For the full recipe, see [Soft push permission](/recipes/soft-push-permission).

## Related

* [Android quickstart](/developer-guide/quickstart-android) — initial setup, not covered here.
* [Tracking events](/developer-guide/tracking-events) — payload shapes and common patterns.
* [Handling deeplinks](/developer-guide/handling-deeplinks) — cross-platform deeplink routing patterns.
* [Soft push permission](/recipes/soft-push-permission) — full preflight recipe.
* [SDK reference: Android](/reference/sdk-android) — full method list.


# React Native integration

In plain English: this page is a collection of how-tos for the Amply React Native SDK beyond "just get it running." It covers peer dependencies, the iOS pod install step, Android autolinking, using the `useAmplySystemEvents` hook, cleaning up listeners so you don't leak memory, and handling re-initialization when the JS bundle reloads. A PM can skim the headings; an engineer copies the TypeScript snippets.

**Use this when** the React Native SDK is installed and you need to wire a specific flow. **Don't use this when** you haven't finished the Quickstart yet — start there.

## Before you start

* React Native 0.79+, Expo SDK 54+ (or bare RN 0.79+) — see [Installation](/developer-guide/installation) for the full requirements matrix.
* New Architecture enabled (the SDK uses the new native module system).
* Node 18+.

## Peer dependencies and install

The SDK requires `react >=18.2.0`, `react-native >=0.79.0`, and `expo >=54.0.0` as peer dependencies — make sure your app has them, then install:

```bash
npm install @amplytools/react-native-amply-sdk
```

## iOS: run pod install

After installing the npm package, refresh the iOS native project. This links the native iOS SDK into your app.

```bash
cd ios
pod install
cd ..
```

Run this every time you change the installed npm version of the SDK.

## Android: autolinking is automatic

On Android there is no manual Gradle edit. The SDK ships a `react-native.config.js` entry that React Native's autolinking picks up on the next build. Just rebuild:

```bash
npx react-native run-android
```

If you use Expo with `expo prebuild`, the SDK's config plugin registers the native package for you during prebuild.

## Initialize once, early

Call `initialize` once, as close to app start as possible. The function returns a Promise that resolves when the native SDK is ready.

**Signature:**

```ts
Amply.initialize(config: AmplyInitializationConfig): Promise<void>

type AmplyInitializationConfig = {
  appId: string;
  apiKeyPublic: string;
  apiKeySecret?: string | null;
  endpoint?: string | null;
  defaultConfig?: string | null;
  debug?: boolean | null;
  logLevel?: LogLevel | null;
};
```

```tsx
// App.tsx
import {useEffect} from 'react';
import Amply from '@amplytools/react-native-amply-sdk';

export default function App() {
  useEffect(() => {
    Amply.initialize({
      appId: 'com.example.app',
      apiKeyPublic: 'pk_...',
      apiKeySecret: 'sk_...',
      logLevel: 'warn',
    }).catch(err => console.warn('[Amply] init failed', err));
  }, []);

  return <RootNavigator />;
}
```

`Amply.isInitialized()` returns `true` synchronously once `initialize` has resolved.

### Custom property value types

RN custom properties accept `string | number | boolean` only. The iOS and Android native SDKs accept more types — stick to primitives on React Native.

## Re-init on Fast Refresh / JS reload

Pressing R in Metro re-runs your JS bundle but the native side stays alive. The native SDK remains initialized across JS reloads. Guarding your init is optional but keeps logs clean:

```ts
if (!Amply.isInitialized()) await Amply.initialize(config);
```

On a cold launch, both JS and native start fresh — always call `initialize` on mount.

## Listening to system events

System events are SDK lifecycle signals: config loaded, session started, campaign evaluated. Two APIs are available.

### Option 1: the hook (good for debug UI)

**Signature:**

```ts
useAmplySystemEvents(options?: {
  maxEntries?: number;       // default 50
  dedupe?: boolean;          // default true
  onEvent?: (event: EventRecord) => void;
}): {events: EventRecord[]; reset: () => void};
```

```tsx
import {useAmplySystemEvents} from '@amplytools/react-native-amply-sdk';

function DebugPanel() {
  const {events, reset} = useAmplySystemEvents({maxEntries: 20});
  return (
    <View>
      {events.map(e => <Text key={e.id}>{e.name} @ {e.timestamp}</Text>)}
      <Button title="Clear" onPress={reset} />
    </View>
  );
}
```

### Option 2: the raw listener (good for production)

Returns a Promise that resolves to an unsubscribe function.

**Signature:**

```ts
Amply.addSystemEventListener(
  listener: (event: EventRecord) => void
): Promise<() => void>;
// Alias: Amply.systemEvents.addListener(listener)
```

```tsx
useEffect(() => {
  let unsubscribe: (() => void) | undefined;
  let cancelled = false;

  Amply.systemEvents.addListener(event => {
    console.log('[Amply]', event.name, event.properties);
  }).then(unsub => {
    if (cancelled) unsub();           // component unmounted before subscribe landed
    else unsubscribe = unsub;
  });

  return () => {
    cancelled = true;
    unsubscribe?.();                  // always call the unsubscribe
  };
}, []);
```

{% hint style="warning" %}
Always call the returned unsubscribe in cleanup. Subscriptions that outlive the component that created them will keep the listener running on stale state and can leak memory across navigations.
{% endhint %}

## Receiving campaign deeplinks

Same Promise-returns-unsubscribe shape.

**Signature:**

```ts
Amply.addDeepLinkListener(
  listener: (event: DeepLinkEvent) => void
): Promise<() => void>;

type DeepLinkEvent = {url: string; info: JsonMap; consumed: boolean};
```

```tsx
useEffect(() => {
  let unsubscribe: (() => void) | undefined;
  let cancelled = false;
  Amply.addDeepLinkListener(event => {
    navigation.navigate('Route', {url: event.url});
  }).then(unsub => { if (cancelled) unsub(); else unsubscribe = unsub; });
  return () => { cancelled = true; unsubscribe?.(); };
}, [navigation]);
```

Declare your URL scheme in `Info.plist` and `AndroidManifest.xml` — see [iOS integration](/developer-guide/ios-integration#registering-your-deeplink-scheme) and [Android integration](/developer-guide/android-integration#registering-deeplink-intent-filters).

## Tracking events and identifying the user

```ts
Amply.track({name: 'ScreenViewed', properties: {screen: 'Home'}});

Amply.setUserId('user_42');
Amply.setCustomProperties({plan: 'premium', seats: 3, trial: false});
await Amply.getCustomProperty('plan');   // 'premium'
Amply.setUserId(null);                   // clear
```

See [Tracking events](/developer-guide/tracking-events) and [User attributes](/developer-guide/user-attributes) for payload rules.

## Tearing down all listeners

For hard resets (user logs out, tests):

```ts
Amply.removeAllListeners();
```

This clears every deeplink subscription the SDK is currently tracking. It does not uninitialize the SDK.

## Log level

```ts
Amply.setLogLevel('debug');   // 'none' | 'error' | 'warn' | 'info' | 'debug'
Amply.getLogLevel();
```

Debug logging is forwarded to `console` automatically when the level is not `'none'`.

## Related

* [React Native quickstart](/developer-guide/quickstart-react-native) — initial setup, not covered here.
* [Tracking events](/developer-guide/tracking-events) — payload shapes and common patterns.
* [Handling deeplinks](/developer-guide/handling-deeplinks) — cross-platform deeplink routing patterns.
* [iOS integration](/developer-guide/ios-integration) — platform-specific flows that still apply under RN.
* [Android integration](/developer-guide/android-integration) — platform-specific flows that still apply under RN.
* [SDK reference: React Native](/reference/sdk-react-native) — full method list.


# Tracking events

Events are the signals your app sends to Amply so campaigns can target, trigger, and measure. This page shows how a developer tells the SDK "something happened" — for example, the user opened a screen, completed a sign-up, or tapped a plan.

A PM handing this off to engineering only needs to answer two questions: **what is the event called**, and **which properties go with it**. The SDK handles batching, retries, and delivery to the Amply backend.

**Use this when** you want the SDK to record a user action so it can drive campaigns or show up in reports. **Don't use this when** you're sending internal framework state the SDK already captures — session start, app open, and config fetches are tracked automatically.

## What an event looks like

Every event has:

* A **name** — a short string you pick. Same name on every platform for the same action.
* A **property map** — optional key/value attributes describing the specifics of this occurrence (which plan, which screen, how much).

The SDK timestamps the event for you and attaches it to the current session.

## Tracking an event

{% tabs %}
{% tab title="iOS (Swift)" %}

```swift
import AmplySDK

// Simple event, no properties
amply.track(event: "OnboardingCompleted", properties: [:])

// Event with properties
amply.track(
    event: "PlanSelected",
    properties: [
        "plan_id": "annual_pro",
        "price_usd": 49.99,
        "trial_days": 7
    ]
)
```

{% endtab %}

{% tab title="Android (Kotlin)" %}

```kotlin
import tools.amply.sdk.Amply

// Simple event, no properties
amply.track("OnboardingCompleted")

// Event with properties
amply.track(
    "PlanSelected",
    mapOf(
        "plan_id" to "annual_pro",
        "price_usd" to 49.99,
        "trial_days" to 7
    )
)
```

{% endtab %}

{% tab title="React Native (TS)" %}

```ts
import Amply from '@amplytools/react-native-amply-sdk';

// Simple event, no properties
await Amply.track({ name: 'OnboardingCompleted' });

// Event with properties
await Amply.track({
  name: 'PlanSelected',
  properties: {
    plan_id: 'annual_pro',
    price_usd: 49.99,
    trial_days: 7,
  },
});
```

{% endtab %}
{% endtabs %}

## Property value types

Use simple scalar values. The accepted types differ slightly between platforms:

| Platform         | Accepted property value types                                                    |
| ---------------- | -------------------------------------------------------------------------------- |
| iOS (Swift)      | `String`, `Int`, `Int64`, `Float`, `Double`, `Bool`, `Date` (or `DateTimeValue`) |
| Android (Kotlin) | `String`, `Int`, `Long`, `Float`, `Double`, `Boolean`, `DateTimeValue`           |
| React Native     | `string`, `number`, `boolean`                                                    |

Do not pass nested objects, arrays, or custom classes as property values. If you need to encode structure, flatten it into multiple keys (`payment_method`, `payment_card_brand`) or serialize to a string before calling `track`.

## Naming conventions

Amply **recommends** **`PascalCase` for event names** (`WorkoutCompleted`, `PaywallDismissed`) and **`snake_case` for property keys** (`plan_tier`, `source_screen`) — events read like action constants, properties like data fields. Names are just strings and the SDK accepts any style; a consistent split simply keeps campaigns and reports readable.

Good:

* `PaywallViewed`
* `SubscriptionStarted`
* `TutorialStepCompleted`

Avoid:

* `paywall_viewed`, `paywall-viewed`, `Paywall Viewed` — mixing styles makes filtering painful
* `click` — too generic; you won't be able to tell clicks apart
* `user_opened_the_paywall_for_the_first_time_after_onboarding` — too long; put the detail in properties

Use the same event name across iOS, Android, and React Native for the same user action. If the same action sends different names per platform, campaign targeting and reports break silently.

## Dos and don'ts

Do:

* Fire an event once per action. One `PlanSelected` per tap, not one per frame.
* Use properties to describe the occurrence, not to encode state (store state in custom properties — see [User attributes](/developer-guide/user-attributes)).
* Keep the property set stable. If `price_usd` was a number yesterday, keep it a number today.

Don't:

* Track events from tight loops or scroll handlers. The SDK batches, but the app still pays to build each map.
* Send personally identifiable information in event properties. Names, emails, phone numbers belong nowhere in an event payload.
* Rename events once campaigns are live. Renames break existing scenarios. Add a new name instead.

## When the event actually ships

`track` returns quickly. The SDK writes the event to local storage, then flushes to the backend on its own schedule. Network hiccups are handled automatically — events are retried.

You do **not** need to await `track` for the event to be recorded. On React Native, `track` returns a `Promise<void>` that resolves when the SDK has accepted the event locally. You can `await` it if you want back-pressure, but most apps fire-and-forget.

## Gating an event

`track` records and returns immediately. At a few moments you may instead want the app to **wait** for a campaign action to finish before continuing — for example, show a rewarded ad before an export, and only export if the user watched it. For those, use `trackGated` and act on the decision it returns. It's the same event you'd otherwise `track`, with a wait. See [Gating actions](/developer-guide/gating-actions).

## Verifying your events land

See [Testing your integration](/developer-guide/testing-your-integration) for how to inspect recent events locally and confirm they reach the dashboard.

## Related

* [User attributes](/developer-guide/user-attributes) — persistent traits about the user, not per-occurrence events
* [Gating actions](/developer-guide/gating-actions) — when an event should pause the flow for a campaign action
* [Handling callbacks](/developer-guide/handling-callbacks) — subscribe to SDK lifecycle events
* [Testing your integration](/developer-guide/testing-your-integration) — confirm events are landing
* [Concepts — Sessions and events](/concepts/sessions-and-events) — why the event model works the way it does


# User attributes

This page covers two related but distinct jobs: telling Amply **who the user is** (an identifier from your backend), and telling Amply **what you know about them** (arbitrary key/value traits).

A PM should read this as: "the developer sets a user ID once the person signs in, and sets custom properties whenever a relevant fact changes (subscription tier, plan, region, preference)."

**Use this when** you want campaigns to target specific users or cohorts, or when you need the same user's data to follow them across sessions and devices. **Don't use this when** you want to record something that just happened — that's an event (see [Tracking events](/developer-guide/tracking-events)).

## User ID vs custom properties

The two concepts look similar but behave differently.

|             | User ID                             | Custom properties                |
| ----------- | ----------------------------------- | -------------------------------- |
| Purpose     | Identify the person across sessions | Describe traits about the person |
| Value       | A single string (your backend ID)   | Many key/value pairs             |
| When to set | On sign-in; clear on sign-out       | Any time a fact changes          |
| Example     | `"user_7391"`                       | `{ tier: "pro", region: "EU" }`  |

Set the user ID once per session when authentication happens. Set custom properties whenever the underlying data changes — there's no need to re-send the same values on every launch.

## Setting the user ID

Pass your stable internal identifier. On sign-out, pass `null` to clear it.

{% tabs %}
{% tab title="iOS (Swift)" %}

```swift
amply.setUserId("user_7391")

// On sign-out
amply.setUserId(nil)
```

{% endtab %}

{% tab title="Android (Kotlin)" %}

```kotlin
amply.setUserId("user_7391")

// On sign-out
amply.setUserId(null)
```

{% endtab %}

{% tab title="React Native (TS)" %}

```ts
import Amply from '@amplytools/react-native-amply-sdk';

Amply.setUserId('user_7391');

// On sign-out
Amply.setUserId(null);
```

{% endtab %}
{% endtabs %}

Do not use the user ID for PII (email, name, phone). Use an opaque identifier from your backend.

## Setting custom properties

Custom properties are batched and persisted by the SDK. The SDK will attach them to every event and make them available to campaign targeting rules.

### Set one property

{% tabs %}
{% tab title="iOS (Swift)" %}

```swift
amply.setCustomProperty(key: "tier", value: "pro")
```

{% endtab %}

{% tab title="Android (Kotlin)" %}

```kotlin
amply.setCustomProperty("tier", "pro")
```

{% endtab %}

{% tab title="React Native (TS)" %}

```ts
Amply.setCustomProperty('tier', 'pro');
```

{% endtab %}
{% endtabs %}

### Set many at once (preferred for multiple updates)

{% tabs %}
{% tab title="iOS (Swift)" %}

```swift
amply.setCustomProperties(properties: [
    "tier": "pro",
    "region": "EU",
    "trial_days_remaining": 5
])
```

{% endtab %}

{% tab title="Android (Kotlin)" %}

```kotlin
amply.setCustomProperties(mapOf(
    "tier" to "pro",
    "region" to "EU",
    "trial_days_remaining" to 5
))
```

{% endtab %}

{% tab title="React Native (TS)" %}

```ts
Amply.setCustomProperties({
  tier: 'pro',
  region: 'EU',
  trial_days_remaining: 5,
});
```

{% endtab %}
{% endtabs %}

### Read a property

{% tabs %}
{% tab title="iOS (Swift)" %}

```swift
Task {
    let tier = try await amply.getCustomProperty(key: "tier")
    print("tier: \(tier ?? "none")")
}
```

{% endtab %}

{% tab title="Android (Kotlin)" %}

```kotlin
lifecycleScope.launch {
    val tier = amply.getCustomProperty("tier")
    Log.d("App", "tier: $tier")
}
```

{% endtab %}

{% tab title="React Native (TS)" %}

```ts
const tier = await Amply.getCustomProperty('tier');
console.log('tier:', tier);
```

{% endtab %}
{% endtabs %}

### Remove a property or clear everything

{% tabs %}
{% tab title="iOS (Swift)" %}

```swift
amply.removeCustomProperty(key: "trial_days_remaining")
amply.clearCustomProperties()
```

{% endtab %}

{% tab title="Android (Kotlin)" %}

```kotlin
amply.removeCustomProperty("trial_days_remaining")
amply.clearCustomProperties()
```

{% endtab %}

{% tab title="React Native (TS)" %}

```ts
Amply.removeCustomProperty('trial_days_remaining');
Amply.clearCustomProperties();
```

{% endtab %}
{% endtabs %}

## Accepted value types

| Platform         | Accepted custom property value types                                             |
| ---------------- | -------------------------------------------------------------------------------- |
| iOS (Swift)      | `String`, `Int`, `Int64`, `Float`, `Double`, `Bool`, `Date` (or `DateTimeValue`) |
| Android (Kotlin) | `String`, `Int`, `Long`, `Float`, `Double`, `Boolean`, `DateTimeValue`           |
| React Native     | `string`, `number`, `boolean`                                                    |

Keys are strings up to 255 characters. Pick short, stable keys.

## Batching — don't call in a tight loop

Writes go through a persisted store. Calling `setCustomProperty` a hundred times in a loop will work, but it's wasteful — use `setCustomProperties` with a single map instead.

Bad:

```ts
for (const [key, value] of Object.entries(profile)) {
  Amply.setCustomProperty(key, value); // 50 writes
}
```

Good:

```ts
Amply.setCustomProperties(profile); // 1 batched write
```

## What the dashboard sees

Once set, custom properties are available to campaign targeting (`Users where tier is pro and region is EU`) and appear in the user's profile view. Because properties persist on-device and sync to the backend, they survive app restarts.

## Related

* [Tracking events](/developer-guide/tracking-events) — one-off actions vs persistent traits
* [Handling deeplinks](/developer-guide/handling-deeplinks) — routing campaigns after they match a user
* [Concepts — User attributes](/concepts/user-attributes) — how Amply ties a device to a person
* [iOS SDK reference](/reference/sdk-ios) — exact signatures
* [Android SDK reference](/reference/sdk-android) — exact signatures
* [React Native SDK reference](/reference/sdk-react-native) — exact signatures


# Handling deeplinks

When a campaign fires, Amply tells your app where to go — a screen, a sheet, a URL — by sending a **deeplink**. Your app decides what that URL means and whether it was handled.

A PM should read this as: "when a campaign targets this user, the SDK hands the app a URL with some metadata, and the app navigates accordingly. The developer wires that URL into their app's navigation."

**Use this when** you run campaigns that route users to screens, external URLs, or in-app surfaces. **Don't use this when** a campaign only needs to record an event or set a property — no deeplink is involved in those cases.

## What the SDK delivers

When a campaign matches the current user and fires, the SDK invokes your registered listener with:

* **`url`** — the target URL chosen by the campaign (`yourapp://promo/annual-upgrade`, `https://example.com/help`, or a custom scheme you own)
* **`info`** — a map of metadata the campaign attached to this deeplink (campaign ID, variant, anything the PM configured)
* **`consumed`** (React Native only) — whether another listener in the chain already handled this URL

Your listener returns `true` if it handled the URL, `false` otherwise. Returning `true` lets the SDK know the deeplink was acted on.

## Registering a listener

{% tabs %}
{% tab title="iOS (Swift)" %}

```swift
import AmplySDK

class AppDeepLinkHandler: DeepLinkListener {
    func onDeepLink(url: String, info: [String: Any]) -> Bool {
        print("Amply deeplink: \(url) info: \(info)")

        if url.hasPrefix("yourapp://promo/") {
            // Navigate to promo screen
            Router.shared.showPromo(url: url)
            return true
        }

        // Let another handler deal with it
        return false
    }
}

// Somewhere during app startup, after SDK init:
let handler = AppDeepLinkHandler()
let deepLinkToken = amply.registerDeepLinkListener(listener: handler)
```

Hold a strong reference to your listener from your own side so it stays alive for the lifetime of the app, and keep the returned token - `removeDeepLinkListener(token:)` takes the token, not the listener. A listener that lives for the whole app never needs detaching; one owned by a single screen does.
{% endtab %}

{% tab title="Android (Kotlin)" %}

```kotlin
import tools.amply.sdk.actions.DeepLinkListener

class AppDeepLinkHandler : DeepLinkListener {
    override fun onDeepLink(url: String, info: Map<String, Any>): Boolean {
        Log.d("Amply", "deeplink: $url info: $info")

        if (url.startsWith("yourapp://promo/")) {
            Router.showPromo(url)
            return true
        }

        return false
    }
}

// During Application.onCreate(), after amply init:
val handler = AppDeepLinkHandler()
val deepLinkToken = amply.registerDeepLinkListener(handler)
```

{% endtab %}

{% tab title="React Native (TS)" %}

```ts
import Amply from '@amplytools/react-native-amply-sdk';
import { useEffect } from 'react';

export function useAmplyDeepLinks(onPromo: (url: string) => void) {
  useEffect(() => {
    let unsubscribe: (() => void) | undefined;
    let unmounted = false;

    Amply.addDeepLinkListener(event => {
      console.log('Amply deeplink:', event.url, event.info, 'consumed=', event.consumed);

      if (event.url.startsWith('yourapp://promo/')) {
        onPromo(event.url);
      }
    })
      .then(unsub => {
        if (unmounted) unsub();
        else unsubscribe = unsub;
      });

    return () => {
      unmounted = true;
      unsubscribe?.();
    };
  }, [onPromo]);
}
```

`addDeepLinkListener` returns a `Promise<() => void>`. Await it to get the unsubscribe function, and call it on unmount.
{% endtab %}
{% endtabs %}

## Return value (iOS + Android)

`onDeepLink` returns `Boolean`:

* `true` — your listener handled the URL. The SDK treats it as consumed.
* `false` — your listener did not handle this URL. The SDK may hand it to another registered listener, or fall through.

On React Native, the listener does not return a value. Instead, the SDK hands you a `consumed` flag that tells you whether an earlier subscriber already handled this URL.

## Multiple listeners

All three SDKs support multiple listeners. Each listener sees every deeplink. The native iOS and Android SDKs consider the deeplink "consumed" if any listener returns `true`. On React Native, listeners receive a `consumed` flag indicating whether another listener already handled this URL.

In practice, most apps register one listener at app startup and route by URL prefix.

## Withdrawing a listener (native iOS / Android)

Requires apps built with SDK 0.9.0 or later. Registering **adds** a listener rather than replacing one and returns a token, so if you register from an object that is torn down and rebuilt while your app keeps running, call `removeDeepLinkListener(token)` when that object goes away. Otherwise the old listeners stay registered and every later deeplink is delivered to all of them.

Withdrawing takes the **token** the registration returned, so keep the token — a listener written inline is fine. An app that registers once at startup, as above, never needs this. On React Native, unsubscribe with the function you get back from `addDeepLinkListener` — see below.

A listener that throws is treated as "did not handle it", and the URL still reaches the listeners registered after it.

## When the app should wait for the outcome

A deeplink listener is fire-and-forget: the SDK hands you the URL and the campaign is done. Some actions instead need the user's flow to **wait** for them — a rewarded ad before an export, a survey before a cancel. Those are **gates**: you register them separately and the gated call site awaits the outcome. See [Gating actions](/developer-guide/gating-actions). Everything you don't register as a gate stays an ordinary deeplink, handled here.

## React Native: unsubscribing

`addDeepLinkListener` is async and returns an unsubscribe function. Always unsubscribe when the listener's owning component unmounts — otherwise closed-over state will leak:

```ts
useEffect(() => {
  let unsubscribe: (() => void) | undefined;
  Amply.addDeepLinkListener(handler).then(unsub => { unsubscribe = unsub; });
  return () => { unsubscribe?.(); };
}, []);
```

For a full teardown (e.g., sign-out), you can also call `Amply.removeAllListeners()` which unsubscribes every deep link listener the SDK tracks.

## URL schemes

`yourapp://` throughout these docs is a placeholder for **a scheme your app owns and registers** (in `Info.plist` on iOS, your intent filter on Android) — Amply doesn't reserve or ship a deeplink scheme. A campaign simply carries whatever URL string you configure: a custom scheme like `yourapp://promo/123`, or an external `https://` link. Treat the `url` field as an opaque string; parse it however your app normally parses URLs.

External `https://` URLs coming from campaigns are typically opened by native code through the system's URL handling. The deeplink listener still receives them so the app can log or suppress them.

## Testing

You can simulate a campaign-style deeplink from the terminal:

```bash
# iOS Simulator
xcrun simctl openurl booted "yourapp://promo/test"

# Android device or emulator
adb shell am start -a android.intent.action.VIEW -d "yourapp://promo/test" <your.package.name>
```

Note: these open the OS-level URL. Campaign-driven deeplinks are triggered by matching a real campaign; see [Testing your integration](/developer-guide/testing-your-integration) for how to verify end-to-end.

## Related

* [Gating actions](/developer-guide/gating-actions) — for actions the user's flow must wait on (rewarded ad, survey)
* [Showing custom popups](/developer-guide/showing-custom-popups) — when the campaign wants the app to render a popup
* [Handling callbacks](/developer-guide/handling-callbacks) — other SDK events (session, config, init)
* [Concepts — Scenarios and campaigns](/concepts/scenarios-and-campaigns) — how the dashboard decides what deeplink to send
* [iOS SDK reference](/reference/sdk-ios) — exact signatures
* [Android SDK reference](/reference/sdk-android) — exact signatures
* [React Native SDK reference](/reference/sdk-react-native) — exact signatures


# Gating actions

A **gate** lets a campaign pause the user's flow at a moment you choose, run an action (e.g. a rewarded ad), and resume — or back out — based on the outcome. For the product picture and when to reach for this, read [Gating an action](/concepts/gating-an-action) first. This page is the wiring.

There are two halves, and they live in different places:

1. **At the call site** — instead of firing an event and moving on, you `trackGated` the event and `await` a decision before doing the next step. This is opt-in per call site: a moment is gate-able only where you do this.
2. **At app startup** — you `registerGate` for the URLs that are outcome-bearing (the rewarded ad, the survey), supplying a presenter that runs the action and reports how it ended. Everything you don't register stays an ordinary fire-and-forget [deeplink](/developer-guide/handling-deeplinks).

If no campaign gates the event, or the SDK isn't ready, or anything fails, the decision is **proceed** — the gate is fail-open and never traps the user.

## 1. Gate the call site

{% tabs %}
{% tab title="iOS (Swift)" %}

```swift
func handleExport() async {
    let decision = await amply.trackGated(event: "ExportTapped", properties: [:])
    guard case .proceed = decision else { return }   // user backed out — don't export
    exporter.export(currentDocument)                  // your code, your live context
}
```

`trackGated` returns a `GateDecision` — `.proceed(reason:)` or `.cancelled`. Use the plain `guard case .proceed` form unless you specifically want the reason (`.completed` vs `.failOpen`, e.g. to grant a reward only on a real completion).
{% endtab %}

{% tab title="Android (Kotlin)" %}

```kotlin
lifecycleScope.launch {
    val decision = amply.trackGated("ExportTapped")
    if (decision is GateDecision.Proceed) {
        exporter.export(currentDocument)   // your code, your live context
    } // GateDecision.Cancelled -> user backed out, don't export
}
```

`trackGated` is a `suspend` function — call it from a coroutine. Match `GateDecision.Proceed(reason)` if you need `ProceedReason.Completed` vs `FailOpen`.
{% endtab %}

{% tab title="React Native (TS)" %}

```ts
async function handleExport() {
  const decision = await amply.trackGated('ExportTapped');
  if (decision.outcome !== 'proceed') return;   // user backed out
  await exporter.export(currentDocument);
}
```

The promise resolves to `{ outcome: 'proceed', reason: 'completed' | 'failOpen' }` or `{ outcome: 'cancelled' }`. It **never rejects** — any failure resolves to `proceed`.
{% endtab %}
{% endtabs %}

Track the same event with plain `track` elsewhere and it won't gate there. Use `trackGated` only where the call site can honor a wait.

## 2. Register what runs at the gate

Register once, at startup, for each outcome-bearing URL. The presenter runs the action and reports how it ended: completed, dismissed by the user, or unavailable (no fill, failed to load, not ready).

{% tabs %}
{% tab title="iOS (Swift)" %}

```swift
final class RewardedAdPresenter: CampaignPresenter {
    func present(params: [String: String], info: [String: Any], resolution: CampaignResolution) {
        let reward = Int(params["reward"] ?? "") ?? 0   // params come from the campaign URL query
        rewardedAd.show(reward: reward) { result in
            switch result {
            case .earned:  resolution.resolve(result: .completed)
            case .closed:  resolution.resolve(result: .dismissed)     // user backed out
            case .noFill, .failed: resolution.resolve(result: .unavailable)  // -> proceeds (fail-open)
            }
        }
    }
    func dismiss() { rewardedAd.tearDown() }   // SDK calls this if the gate is abandoned
}

// Keep the presenter and the token alive for as long as the gate should exist.
let adPresenter = RewardedAdPresenter()
let adGateToken = amply.registerGate(baseUrl: "yourapp://ad", presenter: adPresenter,
                                     onAbort: .cancel, timeoutMs: 60_000)
```

{% endtab %}

{% tab title="Android (Kotlin)" %}

```kotlin
class RewardedAdPresenter : CampaignPresenter {
    override fun present(params: Map<String, String>, info: Map<String, Any>, resolution: CampaignResolution) {
        val reward = params["reward"]?.toIntOrNull() ?: 0
        rewardedAd.show(reward) { result ->
            when (result) {
                Result.EARNED -> resolution.resolve(CampaignResult.Completed)
                Result.CLOSED -> resolution.resolve(CampaignResult.Dismissed)
                else          -> resolution.resolve(CampaignResult.Unavailable)
            }
        }
    }
    override fun dismiss() { rewardedAd.tearDown() }
}

// Keep the presenter and the token alive for as long as the gate should exist.
val adPresenter = RewardedAdPresenter()
val adGateToken = amply.registerGate("yourapp://ad", adPresenter, onAbort = AbortPolicy.Cancel, timeoutMs = 60_000)
```

{% endtab %}

{% tab title="React Native (TS)" %}

```ts
await amply.registerGate(
  'yourapp://ad',
  (params, info, resolution) => {
    showRewardedAd(Number(params.reward ?? 0))
      .then(r => resolution.completed())   // or .dismissed() / .unavailable()
      .catch(() => resolution.unavailable());
  },
  { onAbort: 'cancel', timeoutMs: 60_000 },
);
```

{% endtab %}
{% endtabs %}

* **Match by base URL.** Register `yourapp://ad`; a campaign firing `yourapp://ad?type=rewarded&reward=10` matches it, and the query (`type`, `reward`, …) arrives as `params`. One registration serves many campaign variants — the dashboard tunes the parameters with no app release.
* **`onAbort`** decides what a user dismissal means: `cancel` (the gate's `.cancelled` — the flow backs out) or `proceed` (continue anyway). It **defaults to `cancel`**, which is what you want for value-exchange gates like rewarded ads; pass `proceed` for gates that should never hold the flow back.
* **`dismiss()` is required** on the presenter (there is no default). The SDK calls it when the gate is abandoned — caller cancelled, or the timeout fired — so you can tear down any UI you put on screen. Implement it even if it's a no-op for your action.
* **`timeoutMs`** is the fail-open deadline (default 60s, paused while the app is backgrounded). If the presenter never reports, the gate proceeds.
* **Outcomes, strictly:** `dismissed` is a *user-initiated* close only. Every infrastructure failure — no fill, failed to show, not ready — is `unavailable`, which proceeds. Never map an infra failure to `dismissed`, or you'd cancel the user's action for your own outage.

## Consent and permissions are not gates

### Withdrawing a gate

`registerGate` returns a token; `unregisterGate(token)` withdraws it.

A gate registered at launch and kept for the life of the app needs no withdrawal. One registered by something shorter-lived does: the registry outlives whatever registered into it, so a gate left behind by a screen that has gone away makes the next gated call on that URL wait out its full timeout with nothing on screen. A withdrawn gate resolves immediately instead — the way it was registered to resolve, per its `onAbort` — and costs the campaign no impression.

React Native holds the tokens for you; there is nothing to do at the JavaScript layer.

Don't `registerGate` ATT / privacy-consent / push-permission URLs. Route them as ordinary [deeplinks](/developer-guide/handling-deeplinks): present the prompt, record the answer as a [user attribute](/developer-guide/user-attributes), and let the flow continue. See [Gating an action — When not to gate](/concepts/gating-an-action#when-not-to-gate).

## Related

* [Gating an action](/concepts/gating-an-action) — the product model, use cases, and the safety promise
* [Handling deeplinks](/developer-guide/handling-deeplinks) — fire-and-forget actions (everything you don't gate)
* [Tracking events](/developer-guide/tracking-events) — `track` vs `trackGated`


# Showing custom popups

Some campaigns don't navigate the user to a new screen — they show a popup (a promo modal, a discount sheet, an in-app announcement) on top of the current screen. The SDK does not render the popup itself. Instead, it tells your app to render a popup by sending a deeplink with information the popup needs.

A PM should read this as: "the campaign tells the app 'show popup X with these parameters' via the normal deeplink mechanism. The app owns the visual — typography, imagery, layout — so the popup always matches the app's design."

**Use this when** you need campaigns to show in-app popups (promos, announcements, upgrade offers). **Don't use this when** the campaign should only navigate to a full screen or open an external URL — that's covered in [Handling deeplinks](/developer-guide/handling-deeplinks).

## How popups are delivered

The SDK uses a single mechanism — the deeplink listener — to hand any campaign action to your app. For popups, the campaign's action configuration emits a deeplink URL that identifies the popup and its parameters. Your app recognizes this URL, parses the parameters from the `info` map, and renders a native popup.

A typical pattern:

* Campaign URL: `yourapp://popup/discount_offer`
* Info map: `{ "title": "20% off", "cta": "Upgrade now", "plan_id": "annual_pro" }`
* Your app matches on `yourapp://popup/` prefix and shows a modal with that content.

This keeps visual rendering in the app (so popups always look native) and keeps content in the dashboard (so the PM can change copy, imagery, and targeting without a release).

## Rendering a popup from a campaign deeplink

{% tabs %}
{% tab title="iOS (Swift)" %}

```swift
import AmplySDK
import UIKit

class PopupHandler: DeepLinkListener {
    func onDeepLink(url: String, info: [String: Any]) -> Bool {
        guard url.hasPrefix("yourapp://popup/") else { return false }

        let popupId = String(url.dropFirst("yourapp://popup/".count))
        let title = info["title"] as? String ?? ""
        let body  = info["body"]  as? String ?? ""
        let cta   = info["cta"]   as? String ?? "OK"

        DispatchQueue.main.async {
            PopupRouter.shared.show(
                id: popupId,
                title: title,
                body: body,
                cta: cta,
                metadata: info
            )
        }
        return true
    }
}

let popupHandler = PopupHandler()
let popupToken = amply.registerDeepLinkListener(listener: popupHandler)
```

{% endtab %}

{% tab title="Android (Kotlin)" %}

```kotlin
import tools.amply.sdk.actions.DeepLinkListener

class PopupHandler(
    private val popupRouter: PopupRouter
) : DeepLinkListener {

    override fun onDeepLink(url: String, info: Map<String, Any>): Boolean {
        if (!url.startsWith("yourapp://popup/")) return false

        val popupId = url.removePrefix("yourapp://popup/")
        val title = info["title"] as? String ?: ""
        val body  = info["body"]  as? String ?: ""
        val cta   = info["cta"]   as? String ?: "OK"

        popupRouter.show(
            id = popupId,
            title = title,
            body = body,
            cta = cta,
            metadata = info
        )
        return true
    }
}

val popupHandler = PopupHandler(popupRouter)
val popupToken = amply.registerDeepLinkListener(popupHandler)
```

{% endtab %}

{% tab title="React Native (TS)" %}

```tsx
import Amply from '@amplytools/react-native-amply-sdk';
import { useEffect } from 'react';
import { usePopupController } from './popups';

export function useAmplyPopups() {
  const popups = usePopupController();

  useEffect(() => {
    let unsubscribe: (() => void) | undefined;
    let unmounted = false;

    Amply.addDeepLinkListener(event => {
      if (!event.url.startsWith('yourapp://popup/')) return;

      const popupId = event.url.replace('yourapp://popup/', '');
      const { title = '', body = '', cta = 'OK' } = event.info as {
        title?: string; body?: string; cta?: string;
      };

      popups.show({ id: popupId, title, body, cta, metadata: event.info });
    })
      .then(unsub => {
        if (unmounted) unsub();
        else unsubscribe = unsub;
      });

    return () => {
      unmounted = true;
      unsubscribe?.();
    };
  }, [popups]);
}
```

{% endtab %}
{% endtabs %}

## Why the app owns the rendering

The SDK deliberately does not render popups. This means:

* Popups always match your app's fonts, colors, and typography.
* Popups can use your existing design system — buttons, cards, animations.
* The popup behaves correctly on every screen (tab bars, keyboards, sheets).
* Accessibility, dark mode, and localization follow your app's rules.

The dashboard controls **what** to show, **when** to show it, and **to whom**. The app controls **how** it looks.

## Recommended pattern

Keep one deeplink handler at app startup. Route by URL prefix:

* `yourapp://screen/...` → navigate to a screen
* `yourapp://popup/...` → render a popup
* `https://...` → open externally or in an in-app browser

This keeps the integration auditable in one place. If a new campaign type appears, you add one more branch.

## What happens if no listener handles the URL

If your listener returns `false` (or never matches the URL prefix), the SDK considers the deeplink unconsumed. No popup appears. In reports, you will see the campaign matched but the action was not consumed — useful for spotting integrations that are missing handling for a new URL prefix.

## Related

* [Handling deeplinks](/developer-guide/handling-deeplinks) — the full deeplink API this page builds on
* [Concepts — Scenarios and campaigns](/concepts/scenarios-and-campaigns) — how the dashboard models popups
* [User Guide — Creating a campaign](/user-guide/creating-a-campaign) — PM's view of configuring a popup campaign


# Handling callbacks

The SDK emits internal lifecycle events — "I initialized," "a session started," "a campaign was shown" — that your app can subscribe to. Most apps ignore them. A few use them for diagnostics, to gate UI until the SDK is ready, or to log campaign impressions into their own analytics tool.

A PM should read this as: "the developer can optionally listen for SDK milestones. Useful for debugging or mirroring campaign impressions into another system, not required for the SDK to work."

**Use this when** you want to react to SDK lifecycle — log initialization timing, block UI until first config arrives, mirror campaign impressions. **Don't use this when** you just want to track app-level actions — that's [Tracking events](/developer-guide/tracking-events).

## What gets emitted

The SDK emits system events that your listener can observe: `SdkInitialized`, `ConfigFetchStarted`, `ConfigFetchFinished`, `SessionStarted`, `SessionFinished`, `CampaignShown`, `EventTriggered`, and `CustomPropertyChanged`. `CampaignResolved` is stats-only and is not delivered to this listener. See [Events](/reference/events) for the full canonical list, each event's exact properties, and which ones are observable here.

Each event carries a `name`, a `timestamp`, a `type` (`system`), and a `properties` map with event-specific details.

## Subscribing to system events

{% tabs %}
{% tab title="iOS (Swift)" %}

```swift
import AmplySDK

class SdkEventsAdapter: SystemEventsListener {
    func onEvent(event: EventInterface) {
        print("Amply system event: \(event.name) props: \(event.properties)")
        // Example: log to your analytics tool
        if event.name == "CampaignShown" {
            Analytics.log("amply_campaign_shown", params: event.properties)
        }
    }
}

// Hold a strong reference from your own side (e.g., on AppDelegate) so the adapter stays alive,
// and hold the token with it - the token is what detaches the listener later.
let adapter = SdkEventsAdapter()
let systemEventsToken = amply.setSystemEventsListener(listener: adapter)
```

{% endtab %}

{% tab title="Android (Kotlin)" %}

```kotlin
import tools.amply.sdk.events.EventInterface
import tools.amply.sdk.events.SystemEventsListener

class SdkEventsAdapter : SystemEventsListener {
    override fun onEvent(event: EventInterface) {
        Log.d("Amply", "system event: ${event.name} props=${event.properties}")
        if (event.name == "CampaignShown") {
            Analytics.log("amply_campaign_shown", event.properties)
        }
    }
}

// Keep both: the adapter, and the token that detaches it.
val adapter = SdkEventsAdapter()
val systemEventsToken = amply.setSystemEventsListener(adapter)
```

{% endtab %}

{% tab title="React Native (TS)" %}

```ts
import Amply, { systemEvents, formatSystemEventLabel } from '@amplytools/react-native-amply-sdk';
import { useEffect } from 'react';

export function useSdkDiagnostics() {
  useEffect(() => {
    let unsubscribe: (() => void) | undefined;
    let unmounted = false;

    systemEvents.addListener(event => {
      console.log('Amply system event:', formatSystemEventLabel(event));
      if (event.name === 'CampaignShown') {
        Analytics.log('amply_campaign_shown', event.properties);
      }
    })
      .then(unsub => {
        if (unmounted) unsub();
        else unsubscribe = unsub;
      });

    return () => {
      unmounted = true;
      unsubscribe?.();
    };
  }, []);
}
```

{% endtab %}
{% endtabs %}

## React Native hook

React Native also exposes a hook that collects recent system events for rendering in a debug view:

```tsx
import { useAmplySystemEvents, formatSystemEventLabel } from '@amplytools/react-native-amply-sdk';

export function SdkEventsLog() {
  const { events, reset } = useAmplySystemEvents({ maxEntries: 50 });

  return (
    <View>
      <Button title="Clear" onPress={reset} />
      {events.map(e => (
        <Text key={`${e.name}-${e.timestamp}`}>
          {formatSystemEventLabel(e)}
        </Text>
      ))}
    </View>
  );
}
```

`useAmplySystemEvents` accepts `{ maxEntries, dedupe, onEvent }`. It manages subscription and cleanup for you.

## Pending events at startup

System events can fire before your listener is registered (for example, `SdkInitialized` fires during construction). The SDK queues these early events and delivers them to the listener as soon as it's set. Register your listener as early as possible — ideally immediately after SDK construction — to see the full timeline.

## When to use this API vs ignore it

Use it:

* Mirroring `CampaignShown` into your own analytics tool to reconcile funnels
* Blocking a loading state until `ConfigFetchFinished` on first launch
* Logging `SessionStarted`/`SessionFinished` timing for diagnostics

Ignore it:

* For normal event tracking — call `track(...)` directly instead
* For deeplink handling — use `registerDeepLinkListener` / `addDeepLinkListener`
* For remote config — the SDK applies it internally

## One listener at a time (native iOS / Android)

On iOS and Android, `setSystemEventsListener` returns a token - pass it to `clearSystemEventsListener(token)` to detach - and replaces any previously-set listener. On React Native, `systemEvents.addListener` adds to a list of subscribers and returns an unsubscribe function per subscription.

## Withdraw the listener when the object registering it goes away

Requires apps built with SDK 0.9.0 or later. On native iOS and Android, registering returns a token: call `clearSystemEventsListener(token)` — and `removeDeepLinkListener(token)` for deep links — when the object you registered is being torn down.

Most apps register once, from the application object, and never need this. It matters when you register from something with a shorter life than the SDK, because the SDK keeps running and keeps calling what it was given. Deep link listeners make this visible fastest: registering **adds** one rather than replacing it, so a component that registers each time it is rebuilt leaves the previous listeners behind and every later deep link is delivered to all of them.

Withdrawing takes the **token**, not the listener, so keep the token — that is the only thing that can detach it. A listener written inline is fine, because what you hold on to is the token rather than the object. Withdrawing with a token that is no longer the current registration does nothing, which is deliberate: it stops a component that is going away from silencing the one that already replaced it. Detach from your own teardown hook rather than the object's deallocation callback, which will not run while the SDK still holds a reference.

`clearSystemEventsListener` clears only if your listener is still the one installed, so it cannot silence a replacement that registered after you. `removeDeepLinkListener` removes the listener you pass — one registration per call, so a listener registered twice must be withdrawn twice.

On React Native you do not call these. The JavaScript `addDeepLinkListener` and `systemEvents.addListener` already give you an unsubscribe function for your own subscriptions.

## Related

* [Tracking events](/developer-guide/tracking-events) — your custom events, not SDK lifecycle
* [Testing your integration](/developer-guide/testing-your-integration) — use these callbacks to confirm initialization and config fetch
* [iOS SDK reference](/reference/sdk-ios) — exact signatures
* [Android SDK reference](/reference/sdk-android) — exact signatures
* [React Native SDK reference](/reference/sdk-react-native) — exact signatures


# Testing your integration

Before shipping, confirm two things: the SDK is running, and events you fire actually reach the Amply dashboard. This page shows the helpers you use to verify each step locally, and the log controls that surface what the SDK is doing internally.

A PM should read this as: "the SDK has built-in diagnostic helpers — the developer can print the last events, inspect the current state the SDK knows about, and turn on verbose logging during integration."

**Use this when** you're integrating the SDK for the first time, debugging a campaign that won't fire, or tracking down why an event doesn't show up in the dashboard.

**Don't use this when** you're investigating a production-only issue — the debug helpers are meant for local or staging builds, and some signals (like session boundaries on iOS) only reproduce on a real device or simulator. Leave `debug` logging off in release builds.

## Turn on verbose logging

During integration, set the log level to `debug`. Production apps should leave it at `none` (or `error`).

{% tabs %}
{% tab title="iOS (Swift)" %}

```swift
amply.setLogLevel(level: "debug")
// or
amply.setLogLevel(level: .debug)
```

{% endtab %}

{% tab title="Android (Kotlin)" %}

```kotlin
import tools.amply.sdk.logging.LogLevel

amply.setLogLevel(LogLevel.DEBUG)
// or
amply.setLogLevel("debug")
```

{% endtab %}

{% tab title="React Native (TS)" %}

```ts
import Amply from '@amplytools/react-native-amply-sdk';

Amply.setLogLevel('debug');
```

You can also set `debug: true` in the initialization config — it's shorthand for `logLevel: 'debug'` and pipes SDK logs into the Metro console.
{% endtab %}
{% endtabs %}

Log levels, least-to-most verbose: `none`, `error`, `warn`, `info`, `debug`.

## Inspect recent events

The SDK keeps a local list of the events it has seen — both the custom ones you tracked and its own system events. Useful for sanity-checking that your `track` calls actually landed before they've been flushed to the backend.

{% tabs %}
{% tab title="iOS (Swift)" %}

```swift
Task {
    let events = try await amply.getRecentEvents(limit: 30)
    for event in events {
        print("\(event.timestamp) \(event.name) \(event.type) \(event.properties)")
    }
}
```

{% endtab %}

{% tab title="Android (Kotlin)" %}

```kotlin
lifecycleScope.launch {
    val events = amply.getRecentEvents(limit = 30)
    events.forEach { e ->
        Log.d("Amply", "${e.timestamp} ${e.name} ${e.type} ${e.properties}")
    }
}
```

{% endtab %}

{% tab title="React Native (TS)" %}

```ts
const events = await Amply.getRecentEvents(30);
console.table(events.map(e => ({
  name: e.name,
  type: e.type,
  ts: new Date(e.timestamp).toISOString(),
})));
```

{% endtab %}
{% endtabs %}

`limit` defaults to 30. Events are returned newest-first.

## Inspect what the SDK knows about the user

`getDataSetSnapshot` returns the current state of a named dataset — device info, user attributes, current session, recent events, or triggered event counts. Use it to verify that `setUserId`, `setCustomProperties`, and your `track` calls have the effect you expect.

Available datasets:

| Dataset           | What's in it                                                                         |
| ----------------- | ------------------------------------------------------------------------------------ |
| `@device`         | Device and app fields (model, OS version, advertising identifier, app build, locale) |
| `@user`           | The user ID and related user-level data                                              |
| `@custom`         | The custom properties you set via `setCustomProperty` / `setCustomProperties`        |
| `@session`        | Current session (start time, session index, source)                                  |
| `@triggeredEvent` | Counts for a specific triggered event name                                           |
| `@events`         | Counts / aggregates for a set of events                                              |

{% tabs %}
{% tab title="iOS (Swift)" %}

```swift
// Simple form — construct the dataset directly.
Task {
    let snapshot = try await amply.getDataSetSnapshot(type: DataSetType.Device())
    print(snapshot)
}

// Alternative — look up a dataset by its string name.
Task {
    guard let deviceSet = DataSetType.Companion.shared.fromString(value: "@device") else { return }
    let snapshot = try await amply.getDataSetSnapshot(type: deviceSet)
    print(snapshot)
}
```

{% endtab %}

{% tab title="Android (Kotlin)" %}

```kotlin
import tools.amply.sdk.datasets.DataSetType

lifecycleScope.launch {
    val snapshot = amply.getDataSetSnapshot(DataSetType.Device)
    Log.d("Amply", "device snapshot: $snapshot")
}
```

{% endtab %}

{% tab title="React Native (TS)" %}

```ts
const device = await Amply.getDataSetSnapshot({ kind: '@device' });
console.log('device snapshot:', device);

const user = await Amply.getDataSetSnapshot({ kind: '@user' });
console.log('user snapshot:', user);
```

{% endtab %}
{% endtabs %}

## Verifying events reach the dashboard

Once the SDK is running with `debug` logging on:

1. Open the Amply dashboard for your application.
2. Fire a test event from the app (a screen view, a button tap).
3. Watch the device console for `[Amply]` log lines confirming the event was queued and flushed.
4. Open the dashboard's event view and look for the event name.

If the event appears in `getRecentEvents` but not in the dashboard after a minute, check:

* The app has network access.
* The app was initialized with the correct `appId`, `apiKeyPublic`, and `apiKeySecret` for your project.
* The SDK logs don't show authentication or network errors (turn log level up to `debug`).

If the event doesn't appear in `getRecentEvents` at all, the call to `track` didn't reach the SDK — check for typos, make sure the SDK is initialized, and that you're not calling from a build variant with a different configuration.

## Quick diagnostics checklist

* `SdkInitialized` fires (see [Handling callbacks](/developer-guide/handling-callbacks))
* `getDataSetSnapshot({ kind: '@device' })` returns a non-empty map
* `getDataSetSnapshot({ kind: '@user' })` reflects the `userId` you set
* `getDataSetSnapshot({ kind: '@custom' })` reflects the custom properties you set
* `getRecentEvents(30)` shows your last few tracked events
* The dashboard shows the same events within \~1 minute

## Related

* [Tracking events](/developer-guide/tracking-events) — what you're verifying
* [User attributes](/developer-guide/user-attributes) — set user ID and custom properties to inspect
* [Handling callbacks](/developer-guide/handling-callbacks) — subscribe to SDK lifecycle for deeper diagnostics
* [iOS SDK reference](/reference/sdk-ios) — exact signatures
* [Android SDK reference](/reference/sdk-android) — exact signatures
* [React Native SDK reference](/reference/sdk-react-native) — exact signatures


# iOS SDK

Public API surface for the Amply iOS SDK. Import the `AmplySDK` framework.

## At a glance

| Group              | Method                                                                                                                                                    |
| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Initialization     | `Amply(config:)`, `AmplyConfig(appId:apiKeyPublic:apiKeySecret:defaultConfig:configBaseUrl:backendBaseUrl:)`                                              |
| Event tracking     | `track(_:properties:)`                                                                                                                                    |
| User attributes    | `setUserId(_:)`, `setCustomProperty(_:value:)`, `setCustomProperties(_:)`, `getCustomProperty(_:)`, `removeCustomProperty(_:)`, `clearCustomProperties()` |
| Deeplinks          | `registerDeepLinkListener(listener:)` → token, `removeDeepLinkListener(token:)`                                                                           |
| System events      | `setSystemEventsListener(listener:)` → token, `clearSystemEventsListener(token:)`                                                                         |
| Session (iOS-only) | `pauseSession()`, `resumeSession()`, `stopSession()`                                                                                                      |
| Data inspection    | `getRecentEvents(limit:)`, `getDataSetSnapshot(type:)`                                                                                                    |
| Logging            | `setLogLevel(_:)`, `getLogLevel()`, `setLogListener(_:)` → token, `clearLogListener(token:)`                                                              |

## Detaching a listener

Registering a listener returns a **token**. Keep it — passing that token back is how you detach:

```
let token = amply.registerDeepLinkListener(listener: router)
// later, when whatever owns `router` goes away:
amply.removeDeepLinkListener(token: token)
```

Detaching with a token that is no longer the current registration does nothing. That is deliberate: if a screen is replaced by a new one that registers first, the departing screen cannot silence its replacement on the way out.

**Who needs to do this.** A listener set up once at launch and kept for the life of the app never has to detach. Anything shorter-lived does — a screen, a feature module. A listener left attached after its owner is gone keeps that owner alive and the SDK keeps calling it.

**Detach where your teardown actually runs.** Do not rely on the object's deallocation hook: while the SDK still holds the listener, that hook does not run. Use whatever explicit teardown point your host gives you.

**Gates work the same way.** `registerGate` returns a token and `unregisterGate` takes it. A gate left registered by a screen that has gone away makes the next gated call on that URL wait out its full timeout with nothing on screen.

## Initialization

### `Amply(config: AmplyConfig)`

Construct the SDK. Construction **is** initialization — there is no separate `initialize()` call. The init block creates internal platform components, starts the first session, and delivers any system events emitted during startup to a listener you set later.

Parameters:

| Name     | Type          | Required | Description                                 |
| -------- | ------------- | -------- | ------------------------------------------- |
| `config` | `AmplyConfig` | yes      | Credentials and optional network overrides. |

Retain the returned instance for the lifetime of the app. Creating a second instance is not supported; from 0.8.0 a second construction attaches to the SDK already running rather than starting another one, and any configuration you pass the second time is ignored.

### `AmplyConfig`

Credential and network configuration object.

```swift
let config = AmplyConfig(
    appId: "your.app.id",
    apiKeyPublic: "your-public-key",
    apiKeySecret: "your-secret-key"
)
```

Parameters:

| Name             | Type      | Required | Description                                                                 |
| ---------------- | --------- | -------- | --------------------------------------------------------------------------- |
| `appId`          | `String`  | yes      | Application identifier issued in the Amply dashboard.                       |
| `apiKeyPublic`   | `String`  | yes      | Public API key.                                                             |
| `apiKeySecret`   | `String`  | yes      | Secret API key. Keep it out of source control.                              |
| `defaultConfig`  | `String?` | no       | JSON string of a fallback configuration used until the remote config loads. |
| `configBaseUrl`  | `String?` | no       | Override for the configuration service base URL.                            |
| `backendBaseUrl` | `String?` | no       | Override for the events backend base URL.                                   |

## Event tracking

### `track(event: String, properties: [String: Any] = [:])`

Record a custom event.

Parameters:

| Name         | Type            | Required | Description                                        |
| ------------ | --------------- | -------- | -------------------------------------------------- |
| `event`      | `String`        | yes      | Event name.                                        |
| `properties` | `[String: Any]` | no       | Event properties. Defaults to an empty dictionary. |

Returns `Void`. Events are buffered locally and flushed by the SDK; no network call is tied to this call site.

## Gating

Gate a user-facing step on a campaign's outcome — for example, a rewarded ad before an export. See [Gating actions](/developer-guide/gating-actions) for the full flow.

### `trackGated(event: String, properties: [String: Any] = [:]) async -> GateDecision`

Track an event and **wait** for any gating campaign on it to resolve, then return the decision. If no gate applies, returns `.proceed(reason: .failOpen)` immediately. Never throws except on caller cancellation.

| Name         | Type            | Required | Description                     |
| ------------ | --------------- | -------- | ------------------------------- |
| `event`      | `String`        | yes      | Event name a campaign may gate. |
| `properties` | `[String: Any]` | no       | Event properties.               |

Returns a `GateDecision`. Call from a Swift `async` context.

### `registerGate(baseUrl: String, presenter: CampaignPresenter, onAbort: AbortPolicy = .cancel, timeoutMs: Int64 = 60000) -> ListenerToken`

Register the single gate presenter once at startup. The SDK calls the presenter when a gated campaign needs to show UI, and calls its `dismiss()` if the gate is abandoned. Hold a strong reference to the presenter.

| Name        | Type                | Required | Description                                          |
| ----------- | ------------------- | -------- | ---------------------------------------------------- |
| `baseUrl`   | `String`            | yes      | Base URL the gate resolves against.                  |
| `presenter` | `CampaignPresenter` | yes      | Your presenter (see below).                          |
| `onAbort`   | `AbortPolicy`       | no       | What a user dismiss means. Default `.cancel`.        |
| `timeoutMs` | `Int64`             | no       | Fail-open deadline in milliseconds. Default `60000`. |

### `unregisterGate(token: ListenerToken)`

Withdraws the gate registered with that token. The URL stays gate-able, so a gated call still resolves immediately rather than falling through as an ordinary deeplink - it resolves the way the gate was registered to resolve, and costs the campaign no impression.

A gate registered at launch and kept for the life of the app needs no withdrawal. One owned by something shorter-lived does: the registry outlives whatever registered into it.

### `GateDecision`

Sealed type with two cases:

| Case                | Meaning                                                                      |
| ------------------- | ---------------------------------------------------------------------------- |
| `.proceed(reason:)` | Do the next step. `reason` is a `ProceedReason`.                             |
| `.cancelled`        | User deliberately dismissed a `.cancel`-policy gate. Suppress the next step. |

Match with `if case .proceed = decision { … }` or `decision is GateDecision.Cancelled`.

### `ProceedReason`

| Case         | Meaning                                                                    |
| ------------ | -------------------------------------------------------------------------- |
| `.completed` | The campaign's value-exchange was satisfied (e.g. reward earned).          |
| `.failOpen`  | Nothing to wait on, or the gate couldn't run to a decision. Do not reward. |

### `AbortPolicy`

| Case       | Meaning                                                                             |
| ---------- | ----------------------------------------------------------------------------------- |
| `.cancel`  | (default) A dismiss returns `.cancelled` — true gating, for value-exchange actions. |
| `.proceed` | A dismiss returns `.proceed(reason: .failOpen)` — for consent-style flows.          |

### `CampaignPresenter`

```swift
protocol CampaignPresenter {
    func present(params: [String: String], info: [String: Any], resolution: CampaignResolution)
    func dismiss()
}
```

`present` shows the gated UI and reports the result through the `resolution` handle; `dismiss` tears it down without producing a result (the SDK calls it when the gate is abandoned). Both are required.

### `CampaignResolution` / `CampaignResult`

The `resolution` handle passed to `present(...)` reports the outcome exactly once:

```swift
resolution.resolve(result: .completed)    // value-exchange satisfied → gate proceeds (reason .completed)
resolution.resolve(result: .dismissed)    // user backed out → honors the onAbort policy
resolution.resolve(result: .unavailable)  // couldn't run (no fill / error) → fail-open
```

`CampaignResult`: `.completed` / `.dismissed` / `.unavailable`.

## User attributes

### `setUserId(userId: String?)`

Associate subsequent events with a user identifier. Pass `nil` to clear.

| Name     | Type      | Required | Description                                  |
| -------- | --------- | -------- | -------------------------------------------- |
| `userId` | `String?` | yes      | Stable user identifier, or `nil` to log out. |

Returns `Void`. Survives across sessions until changed.

### `setCustomProperty(key: String, value: Any)`

Set one custom property on the current user.

| Name    | Type     | Required | Description                                                                   |
| ------- | -------- | -------- | ----------------------------------------------------------------------------- |
| `key`   | `String` | yes      | Property key.                                                                 |
| `value` | `Any`    | yes      | One of: `String`, `Int`, `Int64`, `Float`, `Double`, `Bool`, `DateTimeValue`. |

Returns `Void`. Use `DateTimeValue(epochMillis:)` to persist a value as a datetime rather than a plain number.

### `setCustomProperties(properties: [String: Any])`

Set multiple custom properties in one call.

| Name         | Type            | Required | Description                      |
| ------------ | --------------- | -------- | -------------------------------- |
| `properties` | `[String: Any]` | yes      | Map of keys to supported values. |

Returns `Void`. Equivalent to calling `setCustomProperty` per entry.

### `getCustomProperty(key: String) async -> Any?`

Read the current value of a custom property.

| Name  | Type     | Required | Description   |
| ----- | -------- | -------- | ------------- |
| `key` | `String` | yes      | Property key. |

Returns `Any?` — the stored value, or `nil` if unset. Call from a Swift `async` context.

### `removeCustomProperty(key: String)`

Delete a single custom property.

| Name  | Type     | Required | Description             |
| ----- | -------- | -------- | ----------------------- |
| `key` | `String` | yes      | Property key to remove. |

Returns `Void`.

### `clearCustomProperties()`

Remove every custom property for the current user. Returns `Void`.

## Deeplinks

### `registerDeepLinkListener(listener: DeepLinkListener) -> ListenerToken`

Register a handler that receives campaign deep link URLs opened by the SDK.

| Name       | Type               | Required | Description                                           |
| ---------- | ------------------ | -------- | ----------------------------------------------------- |
| `listener` | `DeepLinkListener` | yes      | Handler implementing `onDeepLink(url:info:) -> Bool`. |

Returns `Void`. Multiple listeners can be registered; each is invoked in registration order.

### `DeepLinkListener`

Protocol with a single method:

```swift
protocol DeepLinkListener {
    func onDeepLink(url: String, info: [String: Any]) -> Bool
}
```

Return `true` if your app handled the URL; `false` lets other listeners handle it.

A listener that throws is treated as "did not handle it" — the URL still reaches the listeners registered after it.

### `removeDeepLinkListener(token: ListenerToken)`

Withdrawing takes the **token** returned at registration, not the listener object. A token that is no longer the current registration is ignored, so a component being torn down cannot detach the one that replaced it.

Stop sending deep links to a previously registered listener.

| Name       | Type               | Required | Description               |
| ---------- | ------------------ | -------- | ------------------------- |
| `listener` | `DeepLinkListener` | yes      | The listener to withdraw. |

Returns `Void`. Registering **adds** a listener rather than replacing one, so anything you register from an object that can be torn down and rebuilt must be withdrawn when that object goes away — otherwise the old listeners stay registered and every later deep link is delivered to all of them.

## System events

### `setSystemEventsListener(listener: SystemEventsListener) -> ListenerToken`

Receive SDK lifecycle events (session start, config fetch, campaign shown).

| Name       | Type                   | Required | Description                                     |
| ---------- | ---------------------- | -------- | ----------------------------------------------- |
| `listener` | `SystemEventsListener` | yes      | Handler with a single `onEvent(event:)` method. |

Returns `Void`. Replaces any previously set listener. Events that fire before the listener is set are queued and delivered synchronously at registration time.

### `SystemEventsListener`

```swift
protocol SystemEventsListener {
    func onEvent(event: EventInterface)
}
```

`EventInterface` exposes `name: String`, `timestamp: Int64`, `properties: [String: Any]`, and `type: EventType` (`Custom` or `.system`).

### `clearSystemEventsListener(token: ListenerToken)`

Withdrawing takes the **token** returned at registration, not the listener object. A token that is no longer the current registration is ignored, so a component being torn down cannot detach the one that replaced it.

Stop sending system events to a previously set listener.

| Name       | Type                   | Required | Description               |
| ---------- | ---------------------- | -------- | ------------------------- |
| `listener` | `SystemEventsListener` | yes      | The listener to withdraw. |

Returns `Void`. The listener is cleared **only if it is still the one currently set** — so if a replacement has already registered itself, withdrawing the old one leaves the new one in place. Call it when the object you registered is being torn down.

## Session (iOS-only)

On iOS you drive session boundaries manually from your `UIApplicationDelegate` or `ScenePhase` observer. Android handles this automatically via the activity lifecycle.

### `pauseSession()`

Pause the current session when the app enters the background. Returns `Void`. Call from `applicationDidEnterBackground` or equivalent.

### `resumeSession()`

Resume a paused session or start a new one if the pause exceeded the session timeout. Returns `Void`. Call from `applicationDidBecomeActive` or equivalent.

### `stopSession()`

Finalize and flush the current session. Returns `Void`. Call on app termination.

## Data inspection

### `getRecentEvents(limit: Int32 = 30) async -> [EventInterface]`

Fetch the most recent events tracked in the current install. Intended for in-app debug tools.

| Name    | Type    | Required | Description                                         |
| ------- | ------- | -------- | --------------------------------------------------- |
| `limit` | `Int32` | no       | Maximum number of events to return. Defaults to 30. |

Returns `[EventInterface]` newest-first. Call from a Swift `async` context.

### `getDataSetSnapshot(type: DataSetType) async -> [String: Any]`

Return a snapshot of one dataset the SDK uses for targeting.

| Name   | Type          | Required | Description       |
| ------ | ------------- | -------- | ----------------- |
| `type` | `DataSetType` | yes      | Dataset selector. |

Returns `[String: Any]`. Call from a Swift `async` context.

### `DataSetType`

Sealed type with these variants:

| Variant                                           | Contents                                         |
| ------------------------------------------------- | ------------------------------------------------ |
| `Device`                                          | IDFA, IDFV, model, OS version, locale, timezone. |
| `User`                                            | User ID and identity attributes.                 |
| `Custom`                                          | All custom properties you've set.                |
| `Session`                                         | Current session metadata.                        |
| `TriggeredEvent(countStrategy:params:eventName:)` | Aggregated counts for a specific event.          |
| `Events(data:)`                                   | Counts for a list of event names.                |

## Logging

### `setLogLevel(level: LogLevel)`

Set the SDK log level.

| Name    | Type       | Required | Description                                           |
| ------- | ---------- | -------- | ----------------------------------------------------- |
| `level` | `LogLevel` | yes      | One of `.none`, `.error`, `.warn`, `.info`, `.debug`. |

Returns `Void`. Defaults to `.none`.

### `setLogLevel(level: String?)`

String overload. Accepts `"none"`, `"error"`, `"warn"`, `"info"`, `"debug"` (case-insensitive). Any other value — including `nil` — maps to `.none`. Returns `Void`.

### `getLogLevel() -> LogLevel`

Return the current log level. Takes no parameters.

### `setLogListener(listener: LogListener) -> ListenerToken`

No longer accepts `nil`. Detach with `clearLogListener(token:)`.

Install a handler that receives every SDK log entry above the current level. Pass `nil` to remove the existing listener.

| Name       | Type           | Required | Description                                              |
| ---------- | -------------- | -------- | -------------------------------------------------------- |
| `listener` | `LogListener?` | yes      | Handler implementing `onLog(entry:)`, or `nil` to clear. |

Returns `Void`.

### `LogListener`

```swift
protocol LogListener {
    func onLog(entry: LogEntry)
}
```

`LogEntry` fields: `level: LogLevel`, `category: String`, `message: String`, `timestamp: Int64`, `details: [String: Any]?`.

## Types

### `LogLevel`

Enum: `.none`, `.error`, `.warn`, `.info`, `.debug`. Ordered by verbosity — `.debug` logs everything.

### `DateTimeValue`

```swift
DateTimeValue(epochMillis: Int64)
```

Wrapper used with `setCustomProperty` to mark a value as a datetime rather than a number.

### `EventInterface`

| Field        | Type            | Description             |
| ------------ | --------------- | ----------------------- |
| `name`       | `String`        | Event name.             |
| `timestamp`  | `Int64`         | UTC epoch milliseconds. |
| `properties` | `[String: Any]` | Event properties.       |
| `type`       | `EventType`     | `Custom` or `.system`.  |

### System event names

Well-known values on `EventInterface.name` when `type == .system`:

| Name                    | When it fires                                                                                     |
| ----------------------- | ------------------------------------------------------------------------------------------------- |
| `SdkInitialized`        | SDK finished initialization.                                                                      |
| `ConfigFetchStarted`    | Remote configuration fetch began.                                                                 |
| `ConfigFetchFinished`   | Remote configuration fetch completed.                                                             |
| `SessionStarted`        | Session started. `properties.type` is `"cold"` or `"warm"`.                                       |
| `SessionFinished`       | Session ended.                                                                                    |
| `CampaignShown`         | Campaign impression recorded.                                                                     |
| `EventTriggered`        | A custom event matched a campaign trigger.                                                        |
| `CustomPropertyChanged` | A custom property was set, updated, removed, or cleared. Usable as a campaign trigger.            |
| `CampaignResolved`      | A presented (blocking) campaign action resolved or was skipped (0.5.0+). Recorded for stats only. |

## Related

* [Android SDK reference](/reference/sdk-android) — the Android native platform surface.
* [React Native SDK reference](/reference/sdk-react-native) — TypeScript surface for RN apps.


# Android SDK

Public API surface for the Amply Android SDK. Kotlin signatures are copied verbatim from the source.

## At a glance

| Group           | Function                                                                                                                                                                  |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Initialization  | `Amply(config, application)`, `AmplyConfig(...)`, `amplyConfig { ... }`                                                                                                   |
| Event tracking  | `track(event, properties)`                                                                                                                                                |
| User attributes | `setUserId(userId)`, `setCustomProperty(key, value)`, `setCustomProperties(properties)`, `getCustomProperty(key)`, `removeCustomProperty(key)`, `clearCustomProperties()` |
| Deeplinks       | `registerDeepLinkListener(listener)` → token, `removeDeepLinkListener(token)`                                                                                             |
| System events   | `setSystemEventsListener(listener)` → token, `clearSystemEventsListener(token)`                                                                                           |
| Data inspection | `getRecentEvents(limit)`, `getDataSetSnapshot(type)`                                                                                                                      |
| Logging         | `setLogLevel(level)`, `getLogLevel()`, `setLogListener(listener)`                                                                                                         |

## Detaching a listener

Registering a listener returns a **token**. Keep it — passing that token back is how you detach:

```
val token = amply.registerDeepLinkListener(router)
// later, when whatever owns `router` goes away:
amply.removeDeepLinkListener(token)
```

Detaching with a token that is no longer the current registration does nothing. That is deliberate: if a screen is replaced by a new one that registers first, the departing screen cannot silence its replacement on the way out.

**Who needs to do this.** A listener set up once at launch and kept for the life of the app never has to detach. Anything shorter-lived does — a screen, a feature module. A listener left attached after its owner is gone keeps that owner alive and the SDK keeps calling it.

**Detach where your teardown actually runs.** Do not rely on the object's deallocation hook: while the SDK still holds the listener, that hook does not run. Use whatever explicit teardown point your host gives you.

**Gates work the same way.** `registerGate` returns a token and `unregisterGate` takes it. A gate left registered by a screen that has gone away makes the next gated call on that URL wait out its full timeout with nothing on screen.

## Initialization

### `class Amply(val config: AmplyConfig, val application: Application)`

Construct the SDK. Construction **is** initialization — the `init` block wires internal components, starts the first session, and begins tracking the activity lifecycle. No separate `initialize()` call is required.

Parameters:

| Name          | Type                      | Required | Description                                                              |
| ------------- | ------------------------- | -------- | ------------------------------------------------------------------------ |
| `config`      | `AmplyConfig`             | yes      | Credentials and optional network overrides.                              |
| `application` | `android.app.Application` | yes      | Application instance used for lifecycle callbacks, storage, and context. |

Hold the returned instance for the lifetime of the process (commonly on the `Application` subclass).

### `class AmplyConfig`

Credential and network configuration.

```kotlin
class AmplyConfig(
    val appId: String,
    val apiKeyPublic: String,
    val apiKeySecret: String,
    val defaultConfig: String?,
    val configBaseUrl: String? = null,
    val backendBaseUrl: String? = null,
)
```

| Name             | Type      | Required       | Description                                                                                      |
| ---------------- | --------- | -------------- | ------------------------------------------------------------------------------------------------ |
| `appId`          | `String`  | yes            | Application identifier from the Amply dashboard.                                                 |
| `apiKeyPublic`   | `String`  | yes            | Public API key.                                                                                  |
| `apiKeySecret`   | `String`  | yes            | Secret API key. Keep it out of source control.                                                   |
| `defaultConfig`  | `String?` | yes (nullable) | JSON string of a fallback configuration used until the remote config loads. Pass `null` to skip. |
| `configBaseUrl`  | `String?` | no             | Override for the configuration service base URL.                                                 |
| `backendBaseUrl` | `String?` | no             | Override for the events backend base URL.                                                        |

### `fun amplyConfig(initializer: AmplyConfigBuilder.() -> Unit): AmplyConfig`

Kotlin DSL for building an `AmplyConfig`. Enforces that `appId`, `apiKeyPublic`, and `apiKeySecret` are non-null and throws `IllegalArgumentException` otherwise.

```kotlin
val config = amplyConfig {
    api {
        appId = "your.app.id"
        apiKeyPublic = "your-public-key"
        apiKeySecret = "your-secret-key"
    }
    network {
        configBaseUrl = "https://config.example.com"
        backendBaseUrl = "https://events.example.com"
    }
    defaultConfig = "{ ... }"
}
```

The `api { }` block sets credentials; the `network { }` block sets optional overrides. Returns a fully validated `AmplyConfig`.

## Event tracking

### `fun track(event: String, properties: Map<String, Any> = emptyMap())`

Record a custom event.

| Name         | Type               | Required | Description                                 |
| ------------ | ------------------ | -------- | ------------------------------------------- |
| `event`      | `String`           | yes      | Event name.                                 |
| `properties` | `Map<String, Any>` | no       | Event properties. Defaults to `emptyMap()`. |

Returns `Unit`. Events are persisted locally and flushed by the SDK; no network call is tied to this call site.

## Gating

Gate a user-facing step on a campaign's outcome — for example, a rewarded ad before an export. See [Gating actions](/developer-guide/gating-actions) for the full flow.

### `suspend fun trackGated(event: String, properties: Map<String, Any> = emptyMap()): GateDecision`

Track an event and suspend until any gating campaign on it resolves, then return the decision. If no gate applies, returns `GateDecision.Proceed(ProceedReason.FailOpen)` immediately. Call from a coroutine.

| Name         | Type               | Required | Description                     |
| ------------ | ------------------ | -------- | ------------------------------- |
| `event`      | `String`           | yes      | Event name a campaign may gate. |
| `properties` | `Map<String, Any>` | no       | Event properties.               |

### `fun registerGate(baseUrl: String, presenter: CampaignPresenter, onAbort: AbortPolicy = AbortPolicy.Cancel, timeoutMs: Long = 60_000): ListenerToken`

Register the single gate presenter once at startup. The SDK calls the presenter when a gated campaign needs to show UI, and calls its `dismiss()` if the gate is abandoned.

| Name        | Type                | Required | Description                                              |
| ----------- | ------------------- | -------- | -------------------------------------------------------- |
| `baseUrl`   | `String`            | yes      | Base URL the gate resolves against.                      |
| `presenter` | `CampaignPresenter` | yes      | Your presenter (see below).                              |
| `onAbort`   | `AbortPolicy`       | no       | What a user dismiss means. Default `AbortPolicy.Cancel`. |
| `timeoutMs` | `Long`              | no       | Fail-open deadline in milliseconds. Default `60_000`.    |

### `fun unregisterGate(token: ListenerToken)`

Withdraws the gate registered with that token. The URL stays gate-able, so a gated call still resolves immediately rather than falling through as an ordinary deeplink - it resolves the way the gate was registered to resolve, and costs the campaign no impression.

A gate registered at launch and kept for the life of the app needs no withdrawal. One owned by something shorter-lived does: the registry outlives whatever registered into it.

### `sealed class GateDecision`

| Variant                                       | Meaning                                                                     |
| --------------------------------------------- | --------------------------------------------------------------------------- |
| `GateDecision.Proceed(reason: ProceedReason)` | Do the next step. `reason` distinguishes a satisfied gate from a fail-open. |
| `GateDecision.Cancelled`                      | User deliberately dismissed a `Cancel`-policy gate. Suppress the next step. |

`when (decision) { is GateDecision.Proceed -> … ; is GateDecision.Cancelled -> … }`.

### `enum class ProceedReason`

`ProceedReason.Completed` — the campaign's value-exchange was satisfied (e.g. reward earned). `ProceedReason.FailOpen` — nothing to wait on, or the gate couldn't run to a decision; do not reward.

### `enum class AbortPolicy`

`AbortPolicy.Cancel` (default) — a dismiss returns `GateDecision.Cancelled` (true gating, for value-exchange actions). `AbortPolicy.Proceed` — a dismiss fails open (for consent-style flows).

### `interface CampaignPresenter`

```kotlin
interface CampaignPresenter {
    fun present(params: Map<String, String>, info: Map<String, Any>, resolution: CampaignResolution)
    fun dismiss()
}
```

`present` shows the gated UI and reports the result through the `resolution` handle; `dismiss` tears it down without producing a result (the SDK calls it when the gate is abandoned). Both are required.

### `CampaignResolution` / `enum class CampaignResult`

The `resolution` handle passed to `present(...)` reports the outcome exactly once:

```kotlin
resolution.resolve(CampaignResult.Completed)    // value-exchange satisfied → proceeds (ProceedReason.Completed)
resolution.resolve(CampaignResult.Dismissed)    // user backed out → honors the onAbort policy
resolution.resolve(CampaignResult.Unavailable)  // couldn't run (no fill / error) → fail-open
```

`CampaignResult`: `Completed` / `Dismissed` / `Unavailable`.

## User attributes

### `fun setUserId(userId: String?)`

Associate subsequent events with a user identifier.

| Name     | Type      | Required | Description                                 |
| -------- | --------- | -------- | ------------------------------------------- |
| `userId` | `String?` | yes      | Stable user identifier, or `null` to clear. |

Returns `Unit`. Survives across sessions until changed.

### `fun setCustomProperty(key: String, value: Any)`

Set one custom property on the current user.

| Name    | Type     | Required | Description                                                                     |
| ------- | -------- | -------- | ------------------------------------------------------------------------------- |
| `key`   | `String` | yes      | Property key.                                                                   |
| `value` | `Any`    | yes      | One of: `String`, `Int`, `Long`, `Float`, `Double`, `Boolean`, `DateTimeValue`. |

Returns `Unit`. Use `DateTimeValue(epochMillis)` to persist a value as a datetime rather than as a number.

### `fun setCustomProperties(properties: Map<String, Any>)`

Set multiple custom properties at once.

| Name         | Type               | Required | Description               |
| ------------ | ------------------ | -------- | ------------------------- |
| `properties` | `Map<String, Any>` | yes      | Keys to supported values. |

Returns `Unit`. Equivalent to calling `setCustomProperty` per entry.

### `suspend fun getCustomProperty(key: String): Any?`

Read the current value of a custom property.

| Name  | Type     | Required | Description   |
| ----- | -------- | -------- | ------------- |
| `key` | `String` | yes      | Property key. |

Returns `Any?` — the stored value, or `null` if unset. `suspend` because storage access runs off the main thread.

### `fun removeCustomProperty(key: String)`

Delete a single custom property.

| Name  | Type     | Required | Description             |
| ----- | -------- | -------- | ----------------------- |
| `key` | `String` | yes      | Property key to remove. |

Returns `Unit`.

### `fun clearCustomProperties()`

Remove every custom property on the current user. Returns `Unit`. Takes no parameters.

## Deeplinks

### `fun registerDeepLinkListener(listener: DeepLinkListener): ListenerToken`

Register a handler that receives campaign deep link URLs.

| Name       | Type               | Required | Description                                            |
| ---------- | ------------------ | -------- | ------------------------------------------------------ |
| `listener` | `DeepLinkListener` | yes      | Handler implementing `onDeepLink(url, info): Boolean`. |

Returns `Unit`. Multiple listeners can be registered; each is invoked in registration order.

### `interface DeepLinkListener`

```kotlin
interface DeepLinkListener {
    fun onDeepLink(url: String, info: Map<String, Any>): Boolean
}
```

Return `true` if your code handled the URL; `false` to allow other listeners to handle it.

A listener that throws is treated as "did not handle it" — the URL still reaches the listeners registered after it.

### `fun removeDeepLinkListener(token: ListenerToken)`

Withdrawing takes the **token** returned at registration, not the listener object. A token that is no longer the current registration is ignored, so a component being torn down cannot detach the one that replaced it.

Stop sending deep links to a previously registered listener.

| Name       | Type               | Required | Description               |
| ---------- | ------------------ | -------- | ------------------------- |
| `listener` | `DeepLinkListener` | yes      | The listener to withdraw. |

Returns `Unit`. Registering **adds** a listener rather than replacing one, so anything you register from an object that can be torn down and rebuilt must be withdrawn when that object goes away — otherwise the old listeners stay registered and every later deep link is delivered to all of them.

## System events

### `fun setSystemEventsListener(listener: SystemEventsListener): ListenerToken`

Receive SDK lifecycle events (session start, config fetch, campaign shown).

| Name       | Type                   | Required | Description                               |
| ---------- | ---------------------- | -------- | ----------------------------------------- |
| `listener` | `SystemEventsListener` | yes      | Handler with one `onEvent(event)` method. |

Returns `Unit`. Replaces any previously set listener.

### `interface SystemEventsListener`

```kotlin
interface SystemEventsListener {
    fun onEvent(event: EventInterface)
}
```

### `fun clearSystemEventsListener(token: ListenerToken)`

Withdrawing takes the **token** returned at registration, not the listener object. A token that is no longer the current registration is ignored, so a component being torn down cannot detach the one that replaced it.

Stop sending system events to a previously set listener.

| Name       | Type                   | Required | Description               |
| ---------- | ---------------------- | -------- | ------------------------- |
| `listener` | `SystemEventsListener` | yes      | The listener to withdraw. |

Returns `Unit`. The listener is cleared **only if it is still the one currently set** — so if a replacement has already registered itself, withdrawing the old one leaves the new one in place. Call it when the object you registered is being torn down.

## Data inspection

### `suspend fun getRecentEvents(limit: Int = 30): List<EventInterface>`

Fetch the most recent events tracked in the current install. Intended for in-app debug tools.

| Name    | Type  | Required | Description                               |
| ------- | ----- | -------- | ----------------------------------------- |
| `limit` | `Int` | no       | Maximum number of events. Defaults to 30. |

Returns `List<EventInterface>` newest-first.

### `suspend fun getDataSetSnapshot(type: DataSetType): Map<String, Any>`

Return a snapshot of one dataset the SDK uses for targeting.

| Name   | Type          | Required | Description       |
| ------ | ------------- | -------- | ----------------- |
| `type` | `DataSetType` | yes      | Dataset selector. |

Returns `Map<String, Any>`.

### `sealed class DataSetType`

| Variant                                                        | Value             | Contents                                                                          |
| -------------------------------------------------------------- | ----------------- | --------------------------------------------------------------------------------- |
| `DataSetType.Device`                                           | `@device`         | Advertising ID, model, OS version, locale, timezone.                              |
| `DataSetType.User`                                             | `@user`           | User ID and identity attributes.                                                  |
| `DataSetType.Custom`                                           | `@custom`         | All custom properties you've set.                                                 |
| `DataSetType.Session`                                          | `@session`        | Current session metadata.                                                         |
| `DataSetType.TriggeredEvent(countStrategy, params, eventName)` | `@triggeredEvent` | Aggregated counts for a specific event. `CountStrategy` is `GLOBAL` or `SESSION`. |
| `DataSetType.Events(data)`                                     | `@events`         | Counts for a list of events. Each `Event(name, type, params)`.                    |

## Logging

### `fun setLogLevel(level: LogLevel)`

Set the SDK log level.

| Name    | Type       | Required | Description                                           |
| ------- | ---------- | -------- | ----------------------------------------------------- |
| `level` | `LogLevel` | yes      | `LogLevel.NONE`, `ERROR`, `WARN`, `INFO`, or `DEBUG`. |

Returns `Unit`. Defaults to `NONE`.

### `fun setLogLevel(level: String?)`

String overload. Accepts `"none"`, `"error"`, `"warn"`, `"info"`, `"debug"` (case-insensitive). Any other value — including `null` — maps to `NONE`. Returns `Unit`.

### `fun getLogLevel(): LogLevel`

Return the current log level. Takes no parameters.

### `fun setLogListener(listener: LogListener): ListenerToken`

No longer accepts `null`. Detach with `clearLogListener(token)`.

Install a handler that receives every SDK log entry above the current level. Pass `null` to remove the existing listener.

| Name       | Type           | Required | Description                                      |
| ---------- | -------------- | -------- | ------------------------------------------------ |
| `listener` | `LogListener?` | yes      | Handler with `onLog(entry)`, or `null` to clear. |

Returns `Unit`.

### `interface LogListener`

```kotlin
interface LogListener {
    fun onLog(entry: LogEntry)
}
```

### `data class LogEntry`

| Field       | Type                | Description                                                      |
| ----------- | ------------------- | ---------------------------------------------------------------- |
| `level`     | `LogLevel`          | Severity of the entry.                                           |
| `category`  | `String`            | SDK-assigned category (e.g. `"sdk"`, `"session"`, `"deeplink"`). |
| `message`   | `String`            | Human-readable message.                                          |
| `timestamp` | `Long`              | UTC epoch milliseconds.                                          |
| `details`   | `Map<String, Any>?` | Structured context, if provided.                                 |

## Types

### `enum class LogLevel`

| Value   | Int level |
| ------- | --------- |
| `NONE`  | 0         |
| `ERROR` | 1         |
| `WARN`  | 2         |
| `INFO`  | 3         |
| `DEBUG` | 4         |

Higher values include all lower-severity entries.

### `class DateTimeValue(val epochMillis: Long)`

Wrapper passed to `setCustomProperty` to mark a value as a datetime rather than as a number.

### `interface EventInterface`

| Field        | Type               | Description                               |
| ------------ | ------------------ | ----------------------------------------- |
| `name`       | `String`           | Event name.                               |
| `timestamp`  | `Long`             | UTC epoch milliseconds.                   |
| `properties` | `Map<String, Any>` | Event properties.                         |
| `type`       | `EventType`        | `EventType.CUSTOM` or `EventType.SYSTEM`. |

### System event names

Well-known values on `EventInterface.name` when `type == EventType.SYSTEM`:

| Constant                               | Value                   | When it fires                                                                          |
| -------------------------------------- | ----------------------- | -------------------------------------------------------------------------------------- |
| `SystemEvents.SDK_INITIALIZED`         | `SdkInitialized`        | SDK finished initialization.                                                           |
| `SystemEvents.CONFIG_FETCH_STARTED`    | `ConfigFetchStarted`    | Remote configuration fetch began.                                                      |
| `SystemEvents.CONFIG_FETCH_FINISHED`   | `ConfigFetchFinished`   | Remote configuration fetch completed.                                                  |
| `SystemEvents.SESSION_START`           | `SessionStarted`        | Session started. `properties["type"]` is `"cold"` or `"warm"`.                         |
| `SystemEvents.SESSION_END`             | `SessionFinished`       | Session ended.                                                                         |
| `SystemEvents.CAMPAIGN_SHOWN`          | `CampaignShown`         | Campaign impression recorded.                                                          |
| `SystemEvents.EVENT_TRIGGERED`         | `EventTriggered`        | A custom event matched a campaign trigger.                                             |
| `SystemEvents.CUSTOM_PROPERTY_CHANGED` | `CustomPropertyChanged` | A custom property was set, updated, removed, or cleared. Usable as a campaign trigger. |
| `SystemEvents.CAMPAIGN_RESOLVED`       | `CampaignResolved`      | A presented (blocking) campaign action resolved or was skipped (0.5.0+). Stats only.   |

## Related

* [iOS SDK reference](/reference/sdk-ios) — Swift surface on the other native platform.
* [React Native SDK reference](/reference/sdk-react-native) — TypeScript surface for RN apps.


# React Native SDK

Public API surface for `@amplytools/react-native-amply-sdk`. All functions are exported by name from the package root and are also available on the default export.

## At a glance

| Group           | Function                                                                                                                                                                  |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Initialization  | `initialize(config)`, `isInitialized()`                                                                                                                                   |
| Event tracking  | `track(payload)`                                                                                                                                                          |
| User attributes | `setUserId(userId)`, `setCustomProperty(key, value)`, `setCustomProperties(properties)`, `getCustomProperty(key)`, `removeCustomProperty(key)`, `clearCustomProperties()` |
| Deeplinks       | `addDeepLinkListener(listener)`                                                                                                                                           |
| System events   | `addSystemEventListener(listener)`, `addSystemEventsListener(listener)`, `systemEvents.addListener(listener)`, `useAmplySystemEvents(options)`                            |
| Data inspection | `getRecentEvents(limit)`, `getDataSetSnapshot(type)`                                                                                                                      |
| Logging         | `setLogLevel(level)`, `getLogLevel()`                                                                                                                                     |
| Utilities       | `formatSystemEventLabel(event, options)`, `removeAllListeners()`                                                                                                          |

## Initialization

### `initialize(config: AmplyInitializationConfig): Promise<void>`

Configure the SDK and start the first session. Call once at app startup before any other SDK call.

| Name     | Type                        | Required | Description            |
| -------- | --------------------------- | -------- | ---------------------- |
| `config` | `AmplyInitializationConfig` | yes      | See the Types section. |

Returns `Promise<void>` that resolves when native init completes. If `config.debug` or `config.logLevel` is set, a console log listener is registered automatically.

### `isInitialized(): boolean`

Synchronously check whether `initialize` has finished. Takes no parameters. Returns `boolean`. Useful for guarding calls in hot-reload environments.

## Event tracking

### `track(payload: TrackEventPayload): Promise<void>`

Record a custom event.

| Name      | Type                | Required | Description              |
| --------- | ------------------- | -------- | ------------------------ |
| `payload` | `TrackEventPayload` | yes      | `{ name, properties? }`. |

Returns `Promise<void>` resolved after the event is handed to the native queue. Events are persisted locally and flushed by the SDK.

## Gating

Gate a user-facing step on a campaign's outcome — for example, a rewarded ad before an export. See [Gating actions](/developer-guide/gating-actions) for the full flow.

### `trackGated(event: string, properties?: Record<string, CustomPropertyValue>): Promise<GateDecision>`

Track an event and wait for any gating campaign on it to resolve, then resolve with the decision. If no gate applies, resolves to `{ outcome: 'proceed', reason: 'failOpen' }` immediately. **Never rejects.**

| Name         | Type                                  | Required | Description                     |
| ------------ | ------------------------------------- | -------- | ------------------------------- |
| `event`      | `string`                              | yes      | Event name a campaign may gate. |
| `properties` | `Record<string, CustomPropertyValue>` | no       | Event properties.               |

### `registerGate(baseUrl: string, presenter: GatePresenter, options?: GateOptions): Promise<() => void>`

Register the single gate presenter once at startup. Resolves to an **unregister** function. The SDK calls the presenter when a gated campaign needs to show UI.

| Name        | Type            | Required | Description                                                                                                   |
| ----------- | --------------- | -------- | ------------------------------------------------------------------------------------------------------------- |
| `baseUrl`   | `string`        | yes      | Base URL the gate resolves against.                                                                           |
| `presenter` | `GatePresenter` | yes      | `(params, info, resolution) => void` — shows the gated UI.                                                    |
| `options`   | `GateOptions`   | no       | `{ onAbort?: 'cancel' \| 'proceed'; timeoutMs?: number }`. Defaults: `onAbort: 'cancel'`, `timeoutMs: 60000`. |

### `GateDecision`

A plain object, one of:

| Shape                                                       | Meaning                                                                                                                                       |
| ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `{ outcome: 'proceed', reason: 'completed' \| 'failOpen' }` | Do the next step. `completed` = value-exchange satisfied (reward earned); `failOpen` = nothing to wait on / couldn't resolve — do not reward. |
| `{ outcome: 'cancelled' }`                                  | User deliberately dismissed a `cancel`-policy gate. Suppress the next step.                                                                   |

Branch on `decision.outcome === 'proceed'`.

### Resolution (presenter callback)

The third argument the SDK passes to your `presenter` reports the outcome exactly once:

* `resolution.completed()` — value-exchange satisfied → the gate proceeds.
* `resolution.dismissed()` — user backed out → honors the `onAbort` option.
* `resolution.unavailable()` — couldn't run (no fill / error) → fail-open.

## User attributes

### `setUserId(userId: string | null): void`

Associate subsequent events with a user identifier. Pass `null` to clear.

| Name     | Type             | Required | Description                                   |
| -------- | ---------------- | -------- | --------------------------------------------- |
| `userId` | `string \| null` | yes      | Stable user identifier, or `null` to log out. |

Returns `void`. Survives across sessions until changed.

### `setCustomProperty(key: string, value: CustomPropertyValue): void`

Set one custom property.

| Name    | Type                  | Required | Description                                                   |
| ------- | --------------------- | -------- | ------------------------------------------------------------- |
| `key`   | `string`              | yes      | Property key (max 32 characters).                             |
| `value` | `CustomPropertyValue` | yes      | `string`, `number`, or `boolean`. Strings max 255 characters. |

Returns `void`. Implemented as a `setCustomProperties` call with a single entry.

### `setCustomProperties(properties: Record<string, CustomPropertyValue>): void`

Set multiple custom properties in one call.

| Name         | Type                                  | Required | Description               |
| ------------ | ------------------------------------- | -------- | ------------------------- |
| `properties` | `Record<string, CustomPropertyValue>` | yes      | Keys to supported values. |

Returns `void`.

### `getCustomProperty(key: string): Promise<CustomPropertyValue | null>`

Read the current value of a custom property.

| Name  | Type     | Required | Description   |
| ----- | -------- | -------- | ------------- |
| `key` | `string` | yes      | Property key. |

Returns `Promise<CustomPropertyValue | null>` — the stored value or `null` if unset.

### `removeCustomProperty(key: string): void`

Delete a single custom property.

| Name  | Type     | Required | Description             |
| ----- | -------- | -------- | ----------------------- |
| `key` | `string` | yes      | Property key to remove. |

Returns `void`.

### `clearCustomProperties(): void`

Remove every custom property on the current user. Takes no parameters. Returns `void`.

## Deeplinks

### `addDeepLinkListener(listener: (event: DeepLinkEvent) => void): Promise<() => void>`

Register a handler for campaign deep link URLs. Registers the native listener on the first call and reuses it for subsequent subscribers.

| Name       | Type                             | Required | Description                      |
| ---------- | -------------------------------- | -------- | -------------------------------- |
| `listener` | `(event: DeepLinkEvent) => void` | yes      | Called for each deep link event. |

Returns `Promise<() => void>` — an unsubscribe function. Invoke it (for example in a `useEffect` cleanup) to stop receiving events.

## System events

For the full list of system-event names and their payloads (`SessionStarted`, `CustomPropertyChanged`, `CampaignResolved`, …), see [Events](/reference/events). The methods below subscribe to them.

### `addSystemEventListener(listener: (event: EventRecord) => void): Promise<() => void>`

Register a handler for SDK lifecycle events. `DebugLog` events are filtered out and handled by the console output listener.

| Name       | Type                           | Required | Description                   |
| ---------- | ------------------------------ | -------- | ----------------------------- |
| `listener` | `(event: EventRecord) => void` | yes      | Called for each system event. |

Returns `Promise<() => void>` — an unsubscribe function.

### `addSystemEventsListener(listener: (event: EventRecord) => void): Promise<() => void>`

Alias for `addSystemEventListener`. Same parameters, same return.

### `systemEvents.addListener(listener: (event: EventRecord) => void): Promise<() => void>`

Namespaced alias for `addSystemEventListener`. Same parameters, same return.

### `useAmplySystemEvents(options?: UseAmplySystemEventsOptions): UseAmplySystemEventsResult`

React hook that subscribes to system events and returns the accumulated list.

| Name                 | Type                           | Required | Description                                                                       |
| -------------------- | ------------------------------ | -------- | --------------------------------------------------------------------------------- |
| `options.maxEntries` | `number`                       | no       | Maximum entries kept in state. Defaults to 50.                                    |
| `options.dedupe`     | `boolean`                      | no       | If `true`, drops events with the same `name`+`timestamp` key. Defaults to `true`. |
| `options.onEvent`    | `(event: EventRecord) => void` | no       | Side-effect callback invoked for every non-deduped event.                         |

Returns `{ events: EventRecord[]; reset: () => void }`. `reset()` clears the buffered events and the dedupe cache. The subscription is torn down on unmount.

## Data inspection

### `getRecentEvents(limit: number): Promise<EventRecord[]>`

Fetch the most recent events tracked in the current install. Intended for in-app debug tools.

| Name    | Type     | Required | Description                         |
| ------- | -------- | -------- | ----------------------------------- |
| `limit` | `number` | yes      | Maximum number of events to return. |

Returns `Promise<EventRecord[]>` newest-first.

### `getDataSetSnapshot(type: DataSetType): Promise<DataSetSnapshot>`

Return a snapshot of one dataset the SDK uses for targeting.

| Name   | Type          | Required | Description                  |
| ------ | ------------- | -------- | ---------------------------- |
| `type` | `DataSetType` | yes      | Dataset selector. See Types. |

Returns `Promise<DataSetSnapshot>` — a JSON object whose shape depends on the selector.

## Logging

### `setLogLevel(level: LogLevel): void`

Set the SDK log level at runtime. If `level !== 'none'`, a console log listener is registered automatically.

| Name    | Type       | Required | Description                                            |
| ------- | ---------- | -------- | ------------------------------------------------------ |
| `level` | `LogLevel` | yes      | `'none'`, `'error'`, `'warn'`, `'info'`, or `'debug'`. |

Returns `void`.

### `getLogLevel(): LogLevel`

Return the current log level. Takes no parameters. Returns `LogLevel`.

## Utilities

### `formatSystemEventLabel(event: EventRecord, options?: FormatOptions): string`

Format an `EventRecord` into a short human-readable label. Used by debug panels.

| Name              | Type          | Required | Description                                                                                  |
| ----------------- | ------------- | -------- | -------------------------------------------------------------------------------------------- |
| `event`           | `EventRecord` | yes      | The event to label.                                                                          |
| `options.verbose` | `boolean`     | no       | When `true`, includes detailed campaign info for `ConfigFetchFinished`. Defaults to `false`. |

Returns `string`. Produces a short readable label for each recognized system event name; unknown names return `"System event <name>"`. For the full list of system event names and their properties, see [Events](/reference/events).

### `removeAllListeners(): void`

Invoke every unsubscribe function tracked for deep link listeners. Takes no parameters. Returns `void`. Useful during a full SDK teardown (tests, hot reload).

## Types

### `AmplyInitializationConfig`

```ts
type AmplyInitializationConfig = {
  appId: string;
  apiKeyPublic: string;
  apiKeySecret?: string | null;
  configBaseUrl?: string | null;
  backendBaseUrl?: string | null;
  /** @deprecated older spelling of backendBaseUrl */
  endpoint?: string | null;
  defaultConfig?: string | null;
  debug?: boolean | null;
  logLevel?: LogLevel | null;
};
```

| Field            | Required | Description                                                                                                                                               |
| ---------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `appId`          | yes      | Application identifier from the Amply dashboard.                                                                                                          |
| `apiKeyPublic`   | yes      | Public API key.                                                                                                                                           |
| `apiKeySecret`   | no       | Secret API key.                                                                                                                                           |
| `configBaseUrl`  | no       | Base URL for campaign config. Needed together with `backendBaseUrl` to point the app at a non-production stack — the two are served from different hosts. |
| `backendBaseUrl` | no       | Base URL for session and event delivery.                                                                                                                  |
| `endpoint`       | no       | Deprecated spelling of `backendBaseUrl`, kept for existing callers. Never covered the config host. Ignored when `backendBaseUrl` is set.                  |
| `defaultConfig`  | no       | JSON string used as the fallback config until the remote config loads.                                                                                    |
| `debug`          | no       | Shortcut for `logLevel: 'debug'`.                                                                                                                         |
| `logLevel`       | no       | Explicit log level. Takes precedence over `debug`.                                                                                                        |

### `TrackEventPayload`

```ts
type TrackEventPayload = {
  name: string;
  properties?: JsonMap | null;
};
```

`JsonMap = { [key: string]: unknown }`.

### `EventRecord`

```ts
type EventRecord = {
  id?: string | null;
  name: string;
  type: EventType;         // 'custom' | 'system'
  timestamp: number;       // UTC epoch milliseconds
  properties: JsonMap;
};
```

### `DeepLinkEvent`

```ts
type DeepLinkEvent = {
  url: string;
  info: JsonMap;
  consumed: boolean;       // true if a prior listener handled the URL
};
```

### `DataSetType`

Discriminated union:

```ts
type DataSetType =
  | { kind: '@device' }
  | { kind: '@user' }
  | { kind: '@session' }
  | { kind: '@triggeredEvent'; data: TriggeredEventData }
  | { kind: '@events'; data: EventsDataSetEvent[] };
```

`@custom` (a snapshot of all custom properties) is available on iOS and Android but is **not** exposed in the React Native union. To inspect custom properties in RN, read them back individually with `getCustomProperty(key)`.

Supporting types:

```ts
type TriggeredEventCountStrategy = 'global' | 'session';

type TriggeredEventParam = {
  name: string;
  value: unknown;
};

type TriggeredEventData = {
  countStrategy: TriggeredEventCountStrategy;
  params: TriggeredEventParam[];
  eventName?: string | null;
};

type EventsDataSetEvent = {
  name: string;
  type: EventType;         // 'custom' | 'system'
  params: TriggeredEventParam[];
};
```

### `CustomPropertyValue`

```ts
type CustomPropertyValue = string | number | boolean;
```

Narrower than the native SDKs, which also accept `Long`, `Float`, `Double`, and `DateTimeValue`.

### `LogLevel`

```ts
type LogLevel = 'none' | 'error' | 'warn' | 'info' | 'debug';
```

`'none'` silences all output. `'debug'` includes everything.

### System event names

Well-known values on `EventRecord.name` when `type === 'system'`. See [Events](/reference/events) for the canonical list with full property details and observability notes.

## Related

* [iOS SDK reference](/reference/sdk-ios) — Swift surface for native iOS apps.
* [Android SDK reference](/reference/sdk-android) — Kotlin surface for native Android apps.


# Events

Reference for events the SDK emits on your behalf and events you track yourself.

## Event types

Every event the SDK records has a `type` of either `system` or `custom`:

| Type     | Who emits it               | Example             |
| -------- | -------------------------- | ------------------- |
| `system` | SDK, automatically         | `SessionStarted`    |
| `custom` | Your app, via `track(...)` | `PurchaseCompleted` |

Both flow through the same pipeline and appear together in `getRecentEvents(...)`. System events are also delivered to `SystemEventsListener` as they fire.

***

## Standard events (SDK-emitted)

These fire automatically. You don't need to track them. This is the complete list. Most are **observable** via the system-events listener — subscribe with `setSystemEventsListener(...)` (iOS/Android) or `addSystemEventListener(...)` (React Native). One (`CampaignResolved`) is recorded for server-side stats only and is never a frequency-cap input; the **Observable** column says which events are delivered to your listener.

| Name                    | When it fires                                                                                                                              | Properties                                           | Observable |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------- | ---------- |
| `SdkInitialized`        | Once per session, after the SDK finishes setting up and before the first campaign fetch.                                                   | none                                                 | yes        |
| `ConfigFetchStarted`    | Just before the SDK requests the campaign configuration.                                                                                   | none                                                 | yes        |
| `ConfigFetchFinished`   | When the campaign fetch completes (success or failure).                                                                                    | `campaignCount`, `success`, `campaigns`              | yes        |
| `SessionStarted`        | When a new session begins — either cold (first launch after install or full restart) or warm (app returning to foreground after timeout).  | `type`                                               | yes        |
| `SessionFinished`       | When the current session ends.                                                                                                             | none                                                 | yes        |
| `CampaignShown`         | When a campaign is activated and delivered in response to an event.                                                                        | `source`, `campaignId`                               | yes        |
| `EventTriggered`        | After every `track(...)` call, mirroring the custom event that was just recorded.                                                          | `sourceEvent`, `properties`                          | yes        |
| `CustomPropertyChanged` | When you set, update, remove, or clear a custom property. Usable as a campaign **trigger** so you can react to the moment a trait changes. | `key`, `oldValue`, `newValue`                        | yes        |
| `CampaignResolved`      | When a presented (blocking) campaign action resolves or is skipped. **0.5.0+.** Mainly for stats — counted per campaign server-side.       | `campaignId`, `result`, `unavailableReason` (0.5.2+) | stats      |

### SdkInitialized

Fires once after construction/initialization finishes. Use this to know when it's safe to read `getDataSetSnapshot(...)` or query cached campaigns.

```
name:       "SdkInitialized"
type:       "system"
properties: {}
```

### ConfigFetchStarted

Fires before the network request that loads the campaign configuration.

```
name:       "ConfigFetchStarted"
type:       "system"
properties: {}
```

### ConfigFetchFinished

Fires after the campaign configuration has been fetched (or failed to fetch). The cached configuration has already been applied by the time this event fires.

```
name:       "ConfigFetchFinished"
type:       "system"
properties: {
  "campaignCount": 3,
  "success":       true,
  "campaigns": [
    { "id": "c_abc", "name": "Welcome offer" },
    { "id": "c_def", "name": "Day-2 check-in" }
  ]
}
```

| Property        | Type    | Description                                         |
| --------------- | ------- | --------------------------------------------------- |
| `campaignCount` | number  | Number of campaigns now active in memory.           |
| `success`       | boolean | `true` if the fetch completed without error.        |
| `campaigns`     | array   | One entry per active campaign with `id` and `name`. |

### SessionStarted

Fires at the start of every session.

```
name:       "SessionStarted"
type:       "system"
properties: { "type": "cold" }
```

| Property | Type   | Description                                                                                                                                                     |
| -------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `type`   | string | `"cold"` for the first session of an install or a full process restart, `"warm"` for a session resumed after the app was backgrounded past the session timeout. |

### SessionFinished

Fires when the current session ends.

```
name:       "SessionFinished"
type:       "system"
properties: {}
```

### EventTriggered

Fires immediately after each `track(...)` call. The original event is mirrored in the `sourceEvent` and `properties` fields. This exists so downstream listeners (dashboards, analytics forwarders) see a uniform system-event stream for every tracked action.

```
name:       "EventTriggered"
type:       "system"
properties: {
  "sourceEvent": "PurchaseCompleted",
  "properties":  { "sku": "premium_yearly", "price": 59.99 }
}
```

| Property      | Type   | Description                                      |
| ------------- | ------ | ------------------------------------------------ |
| `sourceEvent` | string | The `name` of the custom event that was tracked. |
| `properties`  | object | The exact property map passed to `track(...)`.   |

### CampaignShown

Fires when a campaign is activated in response to a triggering event.

```
name:       "CampaignShown"
type:       "system"
properties: {
  "source":     "PurchaseCompleted",
  "campaignId": "c_abc"
}
```

| Property     | Type   | Description                                    |
| ------------ | ------ | ---------------------------------------------- |
| `source`     | string | Name of the event that triggered the campaign. |
| `campaignId` | string | Identifier of the activated campaign.          |

### CustomPropertyChanged

Fires whenever a custom property is set, updated, removed, or cleared — i.e. on every `setCustomProperty(...)`, `removeCustomProperty(...)`, and `clearCustomProperties()`. Because it carries the key and both the previous and new value, you can use it as a campaign **trigger** to react to the moment a trait changes, not just to the trait's current value.

```
name:       "CustomPropertyChanged"
type:       "system"
properties: {
  "key":      "plan_tier",
  "oldValue": "pro",
  "newValue": "free"
}
```

| Property   | Type                | Description                                                                 |
| ---------- | ------------------- | --------------------------------------------------------------------------- |
| `key`      | string              | The custom property that changed.                                           |
| `oldValue` | primitive \| absent | The value before the change. Absent when the property is newly set.         |
| `newValue` | primitive \| absent | The value after the change. Absent when the property is removed or cleared. |

### CampaignResolved

**0.5.0+.** Fires when a campaign's presented (blocking) action finishes — completed, dismissed, unavailable, or skipped. It is emitted mainly for server-side observability (counted per campaign) rather than as a signal your app reacts to, and it is never a frequency-cap input.

```
name:       "CampaignResolved"
type:       "system"
properties: {
  "campaignId":        "c_abc",
  "result":            "Unavailable",
  "unavailableReason": "TimedOut"
}
```

| Property            | Type   | Description                                                                                                                                                                                                                                                                     |
| ------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `campaignId`        | string | Identifier of the campaign whose action resolved.                                                                                                                                                                                                                               |
| `result`            | string | One of `Completed`, `Dismissed`, `Unavailable`, `SkippedNoPresenter`, `SkippedNoContinuation`.                                                                                                                                                                                  |
| `unavailableReason` | string | **0.5.2+.** Present only when `result` is `Unavailable`, to say why the gate failed open: `TimedOut` (the deadline elapsed before the action reported), `PresenterFailed` (the action handler threw), or `PresenterReported` (the handler itself reported it couldn't proceed). |

***

## Custom events

Custom events are the events you define to describe what users do in your app.

### Targeting on events

Events aren't only triggers — campaign audience rules can target them directly: how many times an event has happened for a user (or whether it ever has), when they first or last did it, and property filters so that only matching occurrences count ("`Purchase` where `product` equals `pro`"). Requires apps built with SDK 0.6.1 or later; on older builds such campaigns simply never match. See [Targeting and audiences](/concepts/targeting-and-audiences#targeting-on-past-behavior) for the full model.

### Naming

* Use a short, stable string. Once an event name is in the wild, changing it breaks every campaign targeting it.
* **Recommended:** `PascalCase` for event names and `snake_case` for property keys. Names are just strings — the SDK accepts any value — but this house convention keeps things readable: events read like action constants (`PurchaseCompleted`, `WorkoutCompleted`), properties read like data fields (`plan_tier`, `source_screen`).
* Describe an action, not a screen. `PurchaseCompleted` is useful; `PurchaseScreen` isn't — you'll want to track both the screen view and the purchase as separate events.
* Reserve the system event names above for the SDK. Don't emit an event called `SessionStarted` yourself.

### Suggested naming patterns

| Pattern             | Example                             | Use for                                     |
| ------------------- | ----------------------------------- | ------------------------------------------- |
| `<Object><Verb>`    | `PaywallViewed`, `TrialStarted`     | Lifecycle or funnel steps.                  |
| `<Feature><Action>` | `ExportClicked`, `FilterApplied`    | Feature-level interactions.                 |
| `<Outcome>`         | `PurchaseCompleted`, `SignupFailed` | Terminal events you'll target campaigns on. |

### Property conventions

* Keys are strings. We recommend `snake_case` for property keys (e.g. `plan_tier`, `source_screen`) so they read differently from the `PascalCase` event-name recommendation.
* Keep the set of keys per event name stable. If `PurchaseCompleted` sometimes has `price` and sometimes doesn't, campaigns that depend on `price` will behave inconsistently.
* Prefer machine-readable values. `"currency": "USD"` beats `"currency": "US Dollars"`.
* Don't pack structured blobs into a single string. Use multiple keys instead.

### Property value types

The SDK accepts primitive values only. What counts as a primitive depends on the platform.

| Platform         | Accepted value types                                  |
| ---------------- | ----------------------------------------------------- |
| iOS (native)     | `String`, `Int`, `Int64`, `Float`, `Double`, `Bool`   |
| Android (native) | `String`, `Int`, `Long`, `Float`, `Double`, `Boolean` |
| React Native     | `string`, `number`, `boolean`                         |

Arrays, nested objects, `null`, and date objects are not supported as property values. Serialize dates to a number (Unix milliseconds) or a string (ISO 8601) before passing them.

### Example

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

```swift
amply.track(
  event: "PurchaseCompleted",
  properties: [
    "sku": "premium_yearly",
    "price": 59.99,
    "currency": "USD",
    "is_trial": false
  ]
)
```

{% endtab %}

{% tab title="Android" %}

```kotlin
amply.track(
  "PurchaseCompleted",
  mapOf(
    "sku" to "premium_yearly",
    "price" to 59.99,
    "currency" to "USD",
    "is_trial" to false
  )
)
```

{% endtab %}

{% tab title="React Native" %}

```ts
await amply.track({
  name: "PurchaseCompleted",
  properties: {
    sku: "premium_yearly",
    price: 59.99,
    currency: "USD",
    is_trial: false,
  },
});
```

{% endtab %}
{% endtabs %}

***

## Related

* [SDK reference — iOS](/reference/sdk-ios) — full API, including `track` and `setSystemEventsListener`.
* [SDK reference — Android](/reference/sdk-android) — same for Android.
* [API reference](/reference/api-reference) — how events travel from the SDK to the server.


# API reference

The Amply SDK reaches the backend over a small, signed HTTPS API. Your app never calls these endpoints itself — the SDK handles the request shape, authentication and retries. This page is here so you can see what leaves the device during an integration audit or a security review.

## At a glance

| Method | Path                  | Purpose                                                                 |
| ------ | --------------------- | ----------------------------------------------------------------------- |
| POST   | `/api/v1/session`     | Report sessions, events, and user/device state. The main data channel.  |
| POST   | `/api/v1/attribution` | Report install-attribution data from third-party attribution providers. |
| POST   | `/api/v1/tm/apple`    | Report Apple App Store in-app purchase transactions.                    |

**Base URL:** `https://api.amply.tools`

All endpoints accept and return JSON (`Content-Type: application/json`).

***

## Authentication

Every request identifies your application via `X-Api-Key`, your application's public API key. Session requests are additionally signed, and the SDK does that for you — there is nothing to configure and nothing to compute.

Both the public and secret API keys are issued in your Amply dashboard under the application's settings. You give both to the SDK when you initialise it; that is all either key is for.

### Application lookup

The backend locates your application from `X-Api-Key`. If the key is unknown or the application has been deleted, every endpoint returns `404 Not Found` with the body `{"detail": "Application not found"}`.

***

## POST /api/v1/session

Reports one or more sessions along with their events. This is the endpoint the SDK calls when it flushes its queue.

### Request

```
POST /api/v1/session HTTP/1.1
Host: api.amply.tools
Content-Type: application/json
X-Api-Key: <public key>

{ ... }
```

### Request body

```json
{
  "user": {
    "id": "u_abc123",
    "lastSyncTS": 1714000000,
    "properties": [
      { "name": "plan", "value": "pro" }
    ]
  },
  "device": {
    "id": "d_xyz789",
    "properties": {
      "sdkVersionNormalized": 10203,
      "osVersionNormalized": 170400,
      "appVersionNormalized": 20100,
      "appInstallVersionNormalized": 20000,
      "platform": "iOS",
      "deviceModel": "iPhone15,3",
      "vendorIdentifier": "B1D…",
      "installDatetime": 1713900000,
      "adId": "D4B…",
      "country": "US"
    }
  },
  "sessions": [
    {
      "id": "s_001",
      "number": { "global": 42, "version": 3 },
      "startDate": "2026-04-24T10:00:00+00:00",
      "finishDate": "2026-04-24T10:05:12+00:00",
      "events": [
        {
          "id": "e_001",
          "name": "PurchaseCompleted",
          "type": "custom",
          "timestamp": 1714000200,
          "date": "2026-04-24T10:03:20+00:00",
          "params": [
            { "name": "sku",   "value": "premium_yearly" },
            { "name": "price", "value": 59.99 }
          ]
        }
      ]
    }
  ]
}
```

#### Top-level fields

| Field      | Type   | Required | Description                                                   |
| ---------- | ------ | -------- | ------------------------------------------------------------- |
| `user`     | object | yes      | User identity and custom properties.                          |
| `device`   | object | yes      | Device identity and collected device properties.              |
| `sessions` | array  | yes      | One or more session records. Must contain at least one entry. |

#### `user`

| Field        | Type                     | Required | Description                                                              |
| ------------ | ------------------------ | -------- | ------------------------------------------------------------------------ |
| `id`         | string \| null           | no       | Your application's user identifier, if set via `setUserId(...)`.         |
| `lastSyncTS` | number \| null           | no       | Unix seconds of the last successful sync, used for delta reconciliation. |
| `properties` | array of `{name, value}` | no       | Custom user properties. `value` is `string`, `number`, or `boolean`.     |

#### `device`

| Field        | Type   | Required | Description                                   |
| ------------ | ------ | -------- | --------------------------------------------- |
| `id`         | string | yes      | Stable device identifier assigned by the SDK. |
| `properties` | object | yes      | Device metadata.                              |

`device.properties`:

| Field                         | Type           | Required | Description                                                           |
| ----------------------------- | -------------- | -------- | --------------------------------------------------------------------- |
| `sdkVersionNormalized`        | number         | yes      | SDK version encoded as a comparable integer.                          |
| `osVersionNormalized`         | number         | yes      | OS version encoded as a comparable integer.                           |
| `appVersionNormalized`        | number         | yes      | Current app version encoded as a comparable integer.                  |
| `appInstallVersionNormalized` | number         | yes      | App version at install time, encoded as a comparable integer.         |
| `platform`                    | string         | yes      | `"iOS"` or `"Android"`.                                               |
| `deviceModel`                 | string         | yes      | Device model identifier (for example, `iPhone15,3`).                  |
| `vendorIdentifier`            | string         | yes      | Vendor-scoped device identifier (IDFV on iOS, equivalent on Android). |
| `installDatetime`             | number         | yes      | Unix seconds of the first install.                                    |
| `adId`                        | string \| null | no       | Advertising identifier, if available and permitted.                   |
| `country`                     | string \| null | no       | ISO country code, if resolved.                                        |

#### `sessions[*]`

| Field        | Type                      | Required | Description                                                                                                                                   |
| ------------ | ------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`         | string                    | yes      | Unique session identifier generated by the SDK.                                                                                               |
| `number`     | object                    | yes      | `{ "global": <int>, "version": <int> }` — `global` counts sessions for this device, `version` counts sessions within the current app version. |
| `startDate`  | string (ISO 8601)         | yes      | Session start, with timezone offset.                                                                                                          |
| `finishDate` | string (ISO 8601) \| null | no       | Session end. Null if the session is still open at flush time.                                                                                 |
| `events`     | array                     | no       | Events captured during the session. May be empty.                                                                                             |

#### `sessions[*].events[*]`

| Field       | Type                     | Required | Description                                                    |
| ----------- | ------------------------ | -------- | -------------------------------------------------------------- |
| `id`        | string                   | yes      | Event identifier generated by the SDK.                         |
| `name`      | string                   | yes      | Event name.                                                    |
| `type`      | string                   | yes      | `"custom"` or `"system"`.                                      |
| `timestamp` | number                   | yes      | Unix seconds when the event was recorded.                      |
| `date`      | string (ISO 8601)        | yes      | Same instant as `timestamp`, rendered as an ISO string.        |
| `params`    | array of `{name, value}` | yes      | Event properties. `value` is `string`, `number`, or `boolean`. |

### Successful response

```
HTTP/1.1 200 OK
Content-Type: application/json
Date: Thu, 24 Apr 2026 10:05:13 GMT

{}
```

The SDK reads the server time from the response `Date` header and uses it to correct clock skew on the device. If the header is missing or unparseable, the SDK logs a warning but treats the request as failed for clock-sync purposes.

### Error responses

| Status                     | Meaning                                                                       |
| -------------------------- | ----------------------------------------------------------------------------- |
| `403 Forbidden`            | The request could not be authenticated for this API key.                      |
| `404 Not Found`            | The application for this API key does not exist.                              |
| `422 Unprocessable Entity` | Request body failed validation. The response body lists the offending fields. |
| `5xx`                      | Server-side error. The SDK retries with exponential backoff.                  |

Example validation error:

```json
{
  "error":   "validation_failed",
  "status":  422,
  "message": "Request body failed validation.",
  "fields": [
    {
      "path":    "sessions[0].events[0].name",
      "message": "Event name must not be blank."
    }
  ]
}
```

***

## POST /api/v1/attribution

Reports install-attribution data from third-party attribution providers.

### Request body

```json
{
  "device": {
    "id": "d_xyz789",
    "external_id": "ext-abc"
  },
  "system": {
    "os_version": "17.4"
  },
  "attribution": {
    "adjust": {},
    "apple_search_ads": {
      "orgId": 123456,
      "campaignId": 789,
      "conversionType": "Download",
      "clickDate": "2026-04-23",
      "adGroupId": 456,
      "countryOrRegion": "US",
      "keywordId": 42,
      "adId": 1001
    },
    "apps_flyer": {}
  },
  "custom": {}
}
```

| Field                          | Type           | Required | Description                                                                                       |
| ------------------------------ | -------------- | -------- | ------------------------------------------------------------------------------------------------- |
| `device`                       | object         | yes      | Device identity. `id` is required; `external_id` is optional.                                     |
| `system`                       | object         | yes      | Device system information. Must include `os_version`.                                             |
| `attribution`                  | object         | yes      | Container for per-provider payloads. Unused providers may be omitted or sent as `{}`.             |
| `attribution.adjust`           | object \| null | no       | Adjust attribution payload. Pass-through; schema follows Adjust's attribution response.           |
| `attribution.apple_search_ads` | object \| null | no       | Apple Search Ads attribution. See table below.                                                    |
| `attribution.apps_flyer`       | object \| null | no       | AppsFlyer attribution payload. Pass-through; schema follows AppsFlyer's conversion-data response. |
| `custom`                       | object \| null | no       | Arbitrary custom-attribution object. Stored as-is.                                                |

#### `attribution.apple_search_ads`

All fields are optional. Copy the values verbatim from the Apple Search Ads attribution API response.

| Field             | Type                        |
| ----------------- | --------------------------- |
| `orgId`           | number                      |
| `campaignId`      | number                      |
| `conversionType`  | string                      |
| `clickDate`       | string (date, `YYYY-MM-DD`) |
| `adGroupId`       | number                      |
| `countryOrRegion` | string                      |
| `keywordId`       | number                      |
| `adId`            | number                      |

### Successful response

```
HTTP/1.1 200 OK
Content-Type: application/json

{}
```

### Error responses

| Status                     | Meaning                                              |
| -------------------------- | ---------------------------------------------------- |
| `404 Not Found`            | Missing/invalid `X-Api-Key`, or unknown application. |
| `422 Unprocessable Entity` | Validation failed.                                   |
| `5xx`                      | Server-side error; safe to retry.                    |

Note: this endpoint does not require request signing or replay protection at present.

***

## POST /api/v1/tm/apple

Reports Apple App Store in-app purchase transactions for a device. Send one request per batch of transactions. Each request must include at least one transaction.

### Request body

```json
{
  "device": {
    "id": "d_xyz789",
    "external_id": "ext-abc"
  },
  "transactions": [
    { "id": "2000000123456789" },
    { "id": "2000000123456790" }
  ]
}
```

| Field                | Type   | Required | Description                                                                           |
| -------------------- | ------ | -------- | ------------------------------------------------------------------------------------- |
| `device`             | object | yes      | Device identity. `id` required; `external_id` optional.                               |
| `transactions`       | array  | yes      | One entry per transaction. Must contain at least one item.                            |
| `transactions[*].id` | string | yes      | The transaction ID returned by StoreKit (`originalTransactionId` or `transactionId`). |

The server calls Apple's App Store Server API to fetch the signed transaction and entitlement details, so only the ID is needed in the payload.

### Successful response

```
HTTP/1.1 200 OK
Content-Type: application/json

{}
```

### Error responses

| Status                     | Meaning                                                             |
| -------------------------- | ------------------------------------------------------------------- |
| `404 Not Found`            | Missing/invalid `X-Api-Key`, or unknown application.                |
| `422 Unprocessable Entity` | `transactions` was missing or empty, or a transaction ID was blank. |
| `5xx`                      | Server-side error; safe to retry.                                   |

***

## Idempotency

A session upload that reaches the server twice — after a dropped connection, say — is counted once. The SDK handles this; nothing is required of your app, and a retry never duplicates a user's events.

## Rate limits

No per-endpoint rate limit is enforced today. Apps are expected to batch events — the SDK does this automatically by buffering events and flushing on a schedule rather than per call.

## Retries

* Retry `5xx` and network-level failures with exponential backoff.
* Do not retry `4xx` without fixing the cause — they indicate malformed payloads, expired keys, or a conflicting request ID.
* The SDK keeps unsent events on disk until a successful `2xx` response, so a failed flush does not lose data.

***

## Related

* [Events reference](/reference/events) — event names, types, and property conventions.
* [SDK reference — iOS](/reference/sdk-ios) — the client-side entry points that call these endpoints.
* [SDK reference — Android](/reference/sdk-android) — same for Android.


# MCP tools

What an AI assistant can do in your Amply account over MCP — connecting, the permissions you grant, and every tool.

Amply hosts an MCP server, so an AI assistant can work in your Amply account on your behalf: set an app up, author campaigns, check analytics, and manage price changes. This page lists what it can do. For why you would use it, see [AI-assisted integration](/getting-started/ai-assisted-integration).

There is nothing to install — you point your assistant at Amply.

## Connecting

{% stepper %}
{% step %}

### Add the server

```bash
claude mcp add --transport http amply https://api.amply.tools/mcp
```

Any client that supports a remote MCP server over HTTP takes the same URL.
{% endstep %}

{% step %}

### Authenticate

Run `/mcp` in Claude Code and choose to authenticate. A browser window opens and you sign in to Amply if you are not already signed in.
{% endstep %}

{% step %}

### Approve what it may do

The consent screen lists everything your client asked for and names the host that will receive the access. Read the list, then allow or decline — it is one decision, not a permission-by-permission choice. The table below explains each line.
{% endstep %}
{% endstepper %}

You never give the assistant your password. Your client holds a short-lived access token, and revoking the connection in Amply cuts that token off immediately. The one thing revoking does not undo is an SDK API key the assistant obtained while connected — a key is a credential in its own right and keeps working until you delete it.

{% hint style="info" %}
You may see two notices, and both are expected. One says the result comes back on `localhost`, so only continue if you started the connection yourself. The other says your client registered itself with Amply rather than being pre-approved, so its name and logo are not verified — most MCP clients work this way.

The box just above the buttons always names the host that will receive the access. A client can copy a name and a logo but not that host, so it is the line worth reading.
{% endhint %}

## What you approve

You can revoke the whole connection at any time in the Amply admin, under **Profile settings → Connected apps**.

| Permission                            | What it allows                                                                                                                                                                                                                                                                                             |
| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| View projects and campaigns           | Read-only access to your projects and the campaigns in them.                                                                                                                                                                                                                                               |
| Create draft campaigns                | Drafts only. This permission on its own can never launch a live campaign.                                                                                                                                                                                                                                  |
| View analytics                        | Active users for your apps, impressions and resolutions for your campaigns.                                                                                                                                                                                                                                |
| Start and stop campaigns              | Decides whether real users see a campaign.                                                                                                                                                                                                                                                                 |
| View prices                           | Your product prices and any pending price changes.                                                                                                                                                                                                                                                         |
| Change draft prices                   | Create, change and delete draft price changes and your organization's custom price indexes. Drafts change nothing on their own — sending one to the stores needs the permission below.                                                                                                                     |
| Send price changes to the stores      | Sends price changes to the App Store and Google Play, and cancels or retries ones already sent. Prices really change for customers, and this cannot be undone.                                                                                                                                             |
| Register apps and read their API keys | Creates projects and applications and reads or creates their SDK API keys. Only an organization administrator can use the creating half. **An API key keeps working after you disconnect the assistant** — revoking the connection does not revoke a key, so remove a key you no longer want in the admin. |

## Setting an app up

These are what let an assistant integrate the SDK without you copying values out of the admin.

| Tool                 | What it does                                                                                                                                                  |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `project_create`     | Create a project. Refused if your plan's limit on projects is reached.                                                                                        |
| `applications_list`  | List the applications in a project, with their ids.                                                                                                           |
| `application_create` | Register an application and get its `appId` **and its first API key** in one step. Takes the bundle id — which must match the app exactly — and the platform. |
| `api_keys_list`      | Read an application's API keys, secrets included.                                                                                                             |
| `api_key_create`     | Create an additional key pair for an application. The existing keys keep working.                                                                             |

{% hint style="info" %}
**iOS and Android are two separate applications**, each with its own key pair, even when they share a bundle id. An app that ships on both stores needs both registered, and the SDK initialised with the pair matching the platform it is running on.

Registering the same bundle id and platform twice is refused rather than duplicated, so an assistant re-running a set-up finds the existing application instead of creating a second one.
{% endhint %}

{% hint style="warning" %}
A brand-new key can report events straight away, but campaigns only start reaching it once the first campaign in that project is activated. An empty campaign list on a fresh app is expected, not a broken key.
{% endhint %}

## Projects and campaigns

| Tool                | What it does                                                                                                                              |
| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `projects_list`     | List the projects you have access to.                                                                                                     |
| `campaigns_list`    | List campaigns in a project. Takes the project, an optional name search, and a result limit.                                              |
| `campaign_get`      | Read one campaign in full — type, state, triggering, targeting and content.                                                               |
| `campaign_create`   | Create a campaign. Always a draft, never live. Needs a project, a name, a type and a triggering rule; targeting and content are optional. |
| `campaign_activate` | Publish a draft so real users can receive it. Refused if your plan's limit on active campaigns is reached.                                |
| `campaign_stop`     | Stop a live campaign. As in the admin, a stopped campaign does not go back to draft.                                                      |

There is also `amply_ping`, a connection check that needs no permission at all.

Triggering and targeting have their own vocabulary — repeat modes, frequency limits, comparison operators. Your assistant can read it from Amply directly: the server publishes a campaign targeting reference with worked examples, so you do not have to describe the format to it.

## Analytics

| Tool                      | What it does                                                     |
| ------------------------- | ---------------------------------------------------------------- |
| `statistics_active_users` | Daily or monthly active users for a project, over a date range.  |
| `campaign_statistics`     | Impressions and resolutions for one campaign, over a date range. |

Date ranges are inclusive and may span at most a year.

## Price management

These mirror the [price management](/price-management/price-management) screens.

{% hint style="info" %}
Price management has to be on your plan, and the price-index tools need it as a separate addition. Without it the tools still appear in your client's list and refuse when called — what your client can see is decided by the permissions you granted, not by your plan.
{% endhint %}

| Tool                                                                                   | What it does                                                         |
| -------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
| `price_products_list`                                                                  | Products for an application.                                         |
| `price_product_prices`                                                                 | Current prices for one product.                                      |
| `price_calculate`                                                                      | Preview what a price change would produce, storing nothing.          |
| `price_change_save`                                                                    | Save a price change as a draft batch.                                |
| `price_entry_update`                                                                   | Change or exclude one territory inside a draft batch.                |
| `price_batches_list`, `price_batch_get`                                                | Find and inspect price-change batches.                               |
| `price_batch_delete`                                                                   | Delete a draft batch.                                                |
| `price_indexes_list`, `price_index_create`, `price_index_update`, `price_index_delete` | Manage your custom [price indexes](/price-management/price-indexes). |
| `price_territories_list`                                                               | The territories available for an application.                        |
| `price_batch_activate`                                                                 | Send a batch to the App Store and Google Play.                       |
| `price_batch_cancel`, `price_batch_retry`, `price_batch_resume`                        | Control a batch that has already been sent.                          |

{% hint style="warning" %}
Anything that dispatches to the stores asks the assistant to confirm how many territories it is about to change. A call that has the count wrong fails instead of going through.
{% endhint %}

## What you still do in the admin

Some things are not available over MCP yet. If an assistant tells you it cannot do one of these, it is right rather than broken.

* **Editing a campaign.** There is no edit tool, so an assistant that gets a draft wrong creates another draft rather than correcting the first. Finish or fix a campaign in the admin.
* **Targeting beyond the basics.** On create, an assistant can target by country, application, app version and OS version. Custom properties, install date and event-history conditions are authored in the admin — see [targeting an audience](/user-guide/targeting-an-audience).
* **Filtering a trigger on event properties.** An assistant can trigger a campaign on an event by name, but not on a specific property of that event. A campaign that depends on such a filter — "when `subscription_status` changes to `expired`" rather than "whenever it changes" — has to be built in the admin, or it will fire more often than you meant.

## Migrating from the old local server

Amply used to publish a local MCP server as an npm package, `@amplytools/amply-mcp`. It ran on your machine and signed in with your email and password.

{% hint style="danger" %}
That package is retired and no longer works. Every version is deprecated on npm.
{% endhint %}

If you still have it installed, remove it and connect to the hosted server with the command above. Then delete `~/.amply/credentials.json` — it holds a live sign-in token for your Amply account.

Nothing else carries over. The tools were renamed, so a script or prompt that called the old ones by name has to be pointed at the tool list on this page instead — your assistant reads that list from the server itself, so in practice you just describe what you want and it finds the right call. The old package's repository is archived and no longer describes the hosted server.

## Related

* [AI-assisted integration](/getting-started/ai-assisted-integration) — what this is for and how it fits an integration
* [Events](/reference/events) — the system events campaigns can trigger on
* [API reference](/reference/api-reference) — the underlying REST contract


# Changelog

User-visible changes to the Amply SDKs and dashboard.

## Latest releases

* **iOS SDK** — 0.10.1
* **Android SDK** — 0.10.1 (published to Maven Central as `tools.amply:sdk-android:0.10.1`)
* **React Native SDK** — 0.10.1 (ships the 0.10.1 native SDKs)

## 0.9.1

All platforms (iOS · Android · React Native). **Recommended for every app on 0.8.0 or later** — no API change and nothing to migrate.

**Events are no longer sent twice.** When an upload succeeded but the response did not carry a server time the SDK could read, the SDK treated the whole upload as failed and sent the same events again on its next attempt. The server had already accepted and stored them, so this did not retry a delivery — it duplicated one.

That matters beyond tidiness, because duplicated events are counted. An audience rule on how many times something happened, and a frequency cap that limits how often a campaign may show, both read those counts — so an affected app could have seen a campaign trigger earlier than its rule intended, or a cap reached sooner.

Three situations reached it: a response with no timestamp at all, a response whose body omitted it, and — new in 0.8.0, which is why this is worth upgrading from — a network proxy that rewrites the timestamp to something implausible. The clock reading became more precise in 0.8.0 and, with it, stricter, which widened what counted as unreadable.

Reading the server time stays best-effort on both paths; delivery no longer depends on it.

## 0.9.0

All platforms (iOS · Android · React Native).

**Detaching a listener now works.** Previously, asking the SDK to stop calling a listener could silently have no effect on iOS: the listener stayed registered and the SDK kept calling it long after the code that owned it was gone. Three things followed from that, all fixed here.

* **React Native apps no longer crash on a bundle reload.** In development, reloading the JavaScript bundle while the app was running crashed it — reliably, every reload. Nothing to change in your app: the fix is entirely inside the native bridge and the **JavaScript API is unchanged**.
* **Deeplinks are delivered once, not once per reload.** Each reload used to leave the previous listener behind, so a single deeplink arrived several times over.
* **A gate registered by a screen that has gone away no longer hangs the next call.** It used to wait out the full timeout — up to a minute — showing nothing. It now resolves immediately, the way that gate was registered to resolve, and costs the campaign no impression.

**Native API change (iOS · Android · KMP).** Registering a listener now returns a token, and you pass that token back to detach:

* `registerDeepLinkListener` / `removeDeepLinkListener`
* `setSystemEventsListener` / `clearSystemEventsListener`
* `setLogListener` / `clearLogListener` — `setLogListener` no longer accepts `null`
* `registerGate` / `unregisterGate` — **`unregisterGate` is new**; there was previously no way to withdraw a gate at all

Keep the token wherever you keep the listener. Anything scoped to the whole process — a listener set up once at launch and never removed — needs no change beyond storing the return value. See [iOS](/developer-guide/ios-integration) and [Android](/developer-guide/android-integration) for the per-platform shape.

**React Native apps need no code changes** — the bridge holds the tokens for you.

## 0.8.0

All platforms (iOS · Android · React Native). A fix release: no new capabilities, several defects that could cost you data.

**A failing listener can no longer take the SDK down with it.** A log listener that threw could end the process. On Android and React Native a listener that throws is now contained. On native iOS, catch exceptions inside your own listener — an Objective-C exception raised in your code still ends the process, and the SDK cannot intercept that. React Native apps hit this without writing a log listener at all: setting `debug` or `logLevel` installs one for you.

**`CampaignShown` was announced twice.** If you mirror campaign impressions into your own analytics — which this guide recommends — an impression could be delivered to your listener a second time, so your funnel counted shows that happened once. Fixed. `SessionFinished` could also be missing from the stream entirely; it is now always emitted before the session ends.

**One copy of the SDK, however many times you start it.** If your app constructed the SDK more than once — a second initialization, or a React Native reload — two copies could run side by side: two sessions started where there should have been one, and one copy could stop the other's events from ever being sent, so a whole session went missing from your data. A second construction now attaches to the SDK that is already running, and any configuration passed the second time is ignored.

**Repeated sends of the same events are fixed.** Each background/foreground cycle used to leave the previous session's uploader running, and with events still waiting, several of those could send the same batch — so the same event could arrive more than once. Uploading now stops with the session it belongs to. Data already collected from apps on earlier versions may still contain those repeats, so counts from those versions can read higher than the real number.

**You can now withdraw a listener.** `removeDeepLinkListener` and `clearSystemEventsListener` join the register/set calls they mirror. This matters if you register from something that gets torn down and rebuilt while your app keeps running — registering a deep link listener **adds** one rather than replacing it, so without withdrawing, the old listeners stayed registered and every later deep link went to all of them. React Native apps need no code change for the fixes above; the JavaScript API is unchanged.

**Frequency caps.**

* Two audience conditions that differed only by the type of their value could share a counter, so a campaign with a frequency cap could show when it should not. Affects campaigns published before SDK 0.6.0, where the value type was not recorded.
* A campaign impression resolved while the app was being backgrounded could go uncounted. Each one lost allowed one extra show for the rest of that install's life, and they accumulate. Such impressions are now recorded; a small number can still be missed if the device is under pressure at that moment.

**Also fixed.** Event timestamps now use the server-corrected clock from the first event of every launch, instead of the device clock until the correction loaded. A gated call (`trackGated`) can no longer wait indefinitely behind other campaign work — it fails open on a deadline, like the other steps on that path.

**Breaking, native only.** Two declarations changed on the native surface, so a Swift/Kotlin app touching them needs an edit: `HttpClientInterface.extractServerTime` is removed (it only ever returned local time), and the session-tracker state stream now carries a `SessionEvent` enum instead of integer constants. Neither is reachable from React Native.

## 0.7.1

React Native only.

* Custom `configBaseUrl` / `backendBaseUrl` passed at initialization were being dropped instead of applied. If you point the SDK at your own endpoints, this release is required.
* The Expo config plugin no longer warns on every prebuild.

## 0.7.0

All platforms (iOS · Android · React Native).

**Frequency caps hold under load.** Two triggers arriving in quick succession could both pass a cap that allows only one show. Shows are now counted before the next campaign is evaluated.

**Sessions survive background/foreground churn.** Several defects where a session could be ended while the app was still using it, or where events fired during that window were rejected and lost. Events tracked around backgrounding now land in the session they belong to.

**Event-history targeting got much faster.** Answering a campaign's event conditions no longer rescans everything the app has ever recorded, so the cost no longer grows with how long the app has been installed.

**Fixed:** `getDataSetSnapshot` with `@triggeredEvent` counted every event sharing the name, including system events, and reported a type that disagreed with its own count. Campaign matching was never affected — this is the number you read while debugging.

## 0.6.1

All platforms (iOS · Android · React Native).

Internal improvements to how event conditions are evaluated on the device — no visible changes to authoring or behavior.

Event conditions now require apps built with SDK 0.6.1 or later. On older app builds (including 0.6.0), campaigns that use them simply never match — nothing breaks; they start matching once the app updates its SDK.

## 0.6.0

All platforms (iOS · Android · React Native).

**Event-history targeting** — campaign audience rules can now target what users have done, directly on their events. No extra code and no custom property to maintain: pick an event in the rule builder and the condition evaluates on the device, live and offline, like all targeting.

* **Counts** — "did `Purchase` at least 3 times", "`ExportClicked` has never happened". Operators: exactly, not exactly, more than, fewer than, at least, at most — plus has happened (ever) / has never happened. Counts cover the lifetime of the install.
* **First and last occurrence** — relative ("last `WorkoutCompleted` more than 14 days ago", with an "…or never" variant; "within the last 7 days") and absolute (before / after a date, whole calendar days in UTC).
* **Property filters** — narrow a condition to matching occurrences ("`Purchase` where `product` equals `pro`"); only matching occurrences count, for both counts and first/last dates.

Event conditions require apps built with SDK 0.6.0 or later. On older app builds, campaigns that use them simply never match — nothing breaks.

Fixes in this release:

* Campaigns limited to "no more than N impressions total" now reliably stop at N.
* Install-date conditions using "is" with a specific date now match the entire calendar day.
* A fresh install no longer briefly reports an incorrect install date.

See [Targeting and audiences](/concepts/targeting-and-audiences) for the model and [Targeting an audience](/user-guide/targeting-an-audience) for the dashboard walkthrough.

## 0.5.0

All platforms (iOS · Android · React Native).

**Mediated-gate contract** — campaigns can now be gated on an outcome your app controls.

* `track` — fire-and-forget event tracking, unchanged from earlier releases.
* `trackGated` — tracks an event and suspends the caller until the campaign action at that moment resolves; lets the app await a campaign's outcome (for example, a rewarded ad completing) before continuing its own flow.
* `registerGate` — registers a named gate your app exposes; Amply uses it to attach outcome-bearing actions to the moments you declare.

See [Gating an action](/concepts/gating-an-action) for the concept and [Gating actions](/developer-guide/gating-actions) for the SDK integration.

Install steps and compatibility notes are in the [Quickstart](/getting-started/quickstart) and [Developer Guide](/developer-guide/installation).

## See full history

Complete release notes, including older versions and bug-fix releases, live on GitHub:

* iOS SDK — [github.com/amply-tools/amply-sdk-ios/releases](https://github.com/amply-tools/amply-sdk-ios/releases)
* React Native SDK — [github.com/amply-tools/amply-react-native/releases](https://github.com/amply-tools/amply-react-native/releases)
* Kotlin / Android SDK — published to Maven Central under `tools.amply:sdk-android`; release notes are attached to the matching tags on GitHub.

Dashboard releases are rolled out continuously and are not versioned publicly.

## Related

* [Quickstart](/getting-started/quickstart) — install the current SDK
* [Developer Guide](/developer-guide/installation) — SDK reference


