> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sequenzy.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Mobile SDKs

> Identify contacts, track events, register push tokens and show in-app messages and content cards from iOS, Android, React Native and Flutter apps

The Sequenzy mobile SDKs connect your app to the contact it belongs to. With
one SDK in your app you can:

* Link the installation to a contact when the user signs in.
* Track custom events that start sequences, like `workout_completed`.
* Register the device for [push notifications](./push-notifications) and report
  opens.
* Show [in-app messages](./in-app-messages) (modal, banner or full screen) and
  list content cards in your own screens.

| Platform | Package | Push | In-app UI |
| - | - | - | - |
| iOS | `Sequenzy` (Swift Package Manager) | APNs, handled by the SDK | UIKit modal, banner and full screen |
| Android | `com.sequenzy:sequenzy-android` | Firebase Cloud Messaging, handled by the SDK | Android views |
| React Native | `@sequenzy/react-native` | Token from expo-notifications or React Native Firebase | React Native components |
| Flutter | `sequenzy_flutter` | Token from firebase\_messaging | Flutter widgets |

The React Native SDK is plain TypeScript, so it runs in Expo Go without a
custom build.

## How it works

1. A **publishable key** identifies your workspace. It ships inside your app,
   so it is not a secret. You restrict it to your app's bundle ID and package
   name.
2. When a user signs in, **your backend** mints a short-lived **identity token**
   for their email with your secret API key. The app hands that token to the
   SDK. Only your backend can mint one, so a person can only ever read and
   write their own contact.
3. The SDK sends events, registers the push token and fetches in-app items with
   the publishable key and the identity token.

Never put a secret API key (`seq_live_...`) in an app.

## 1. Create an app key

Go to **Settings → Integrations → Publishable keys** and choose **Create key**.
Under **Allowed app IDs**, add your iOS bundle ID and your Android package name,
for example `com.example.app`. A key with app IDs and no origins works only
from your apps, never from a website.

With the CLI:

```bash theme={null}
sequenzy web-tracking create --name "Mobile app" --app-ids com.example.app,com.example.android
```

The SDKs send the app ID in the `x-sequenzy-app-id` header. Apps cannot prove
who they are, so the app ID list keeps your key from being reused by mistake;
identity tokens are what protect contact data.

## 2. Mint identity tokens on your backend

When a user signs in, your backend calls
[Mint Identity Token](../api-reference/web-tracking-keys/mint-identity) with a
secret API key (scopes `commerce:write`, `automations:trigger` and
`subscribers:write`) and returns `identityToken` to the app:

```bash theme={null}
curl -X POST "https://api.sequenzy.com/api/v1/web-tracking-identities" \
  -H "Authorization: Bearer seq_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{ "publicKey": "seq_pk_...", "email": "jane@example.com" }'
```

Minting also creates the contact if it does not exist yet. Tokens last 24 hours
by default (`ttlHours`, up to 720). Give the SDK an **identity token provider**,
a function that asks your backend for a fresh token, and it renews expired
tokens by itself. If your iOS and Android apps use different keys, mint each
token for the key that app uses. The provider must not call the SDK itself; it
times out after 20 seconds, and after a failure the SDK waits a minute before
asking again.

## 3. Add the SDK

<Tabs>
  <Tab title="iOS">
    Add the package `https://github.com/Sequenzy/sequenzy-swift` in Xcode
    (**File → Add Package Dependencies**) and link the `Sequenzy` product to
    your app.

    ```swift theme={null}
    import Sequenzy

    func application(_ application: UIApplication,
                     didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        Sequenzy.configure(
            publicKey: "seq_pk_...",
            companyId: "your-company-id",
            identityTokenProvider: { email in try await MyBackend.sequenzyToken(for: email) }
        )
        return true
    }

    func application(_ application: UIApplication,
                     didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
        Sequenzy.registerDeviceToken(deviceToken)
    }

    // After sign-in, and on sign-out:
    Sequenzy.identify(email: user.email)
    Sequenzy.reset()

    // Anywhere:
    Sequenzy.track("workout_completed", properties: ["minutes": 30])
    await Sequenzy.requestPushAuthorization()
    ```

    The app ID defaults to the bundle ID. For push images and delivery reports,
    add a Notification Service Extension target that links
    `SequenzyNotificationService` and contains:

    ```swift theme={null}
    import SequenzyNotificationService

    class NotificationService: SequenzyNotificationServiceExtension {}
    ```
  </Tab>

  <Tab title="Android">
    Add the dependency, plus Firebase Messaging if you send push:

    ```kotlin theme={null}
    dependencies {
        implementation("com.sequenzy:sequenzy-android:0.1.0")
        implementation("com.google.firebase:firebase-messaging:24.1.0")
    }
    ```

    ```kotlin theme={null}
    class MyApp : Application() {
        override fun onCreate() {
            super.onCreate()
            Sequenzy.configure(
                this,
                publicKey = "seq_pk_...",
                companyId = "your-company-id",
                identityTokenProvider = { email -> myBackend.sequenzyToken(email) },
            )
        }
    }

    // After sign-in, and on sign-out:
    Sequenzy.identify(user.email)
    Sequenzy.reset()

    Sequenzy.track("workout_completed", mapOf("minutes" to 30))
    ```

    The app ID defaults to the package name. The SDK's messaging service
    registers FCM tokens and shows pushes that arrive while the app is open. If
    your app has its own `FirebaseMessagingService`, call
    `SequenzyMessagingService.handleNewToken(token)` and
    `SequenzyMessagingService.handleMessage(this, message)` from it. On Android
    13 and later, ask for the `POST_NOTIFICATIONS` permission. If your launcher
    activity is `singleTop` or `singleTask`, call
    `Sequenzy.handleIntent(this, intent)` from its `onNewIntent` so taps on
    notifications that arrive while the app is open are reported.
  </Tab>

  <Tab title="React Native">
    ```bash theme={null}
    npx expo install @sequenzy/react-native @react-native-async-storage/async-storage expo-application expo-notifications
    ```

    ```tsx theme={null}
    import { Sequenzy, SequenzyInAppHost } from "@sequenzy/react-native";
    import * as Application from "expo-application";
    import * as Notifications from "expo-notifications";
    import { Platform } from "react-native";

    Sequenzy.configure({
      publicKey: "seq_pk_...",
      companyId: "your-company-id",
      appId: Application.applicationId!,
      platform: Platform.OS === "ios" ? "ios" : "android",
      identityTokenProvider: (email) => myBackend.sequenzyToken(email),
    });

    export default function App() {
      return (
        <>
          <Navigation />
          <SequenzyInAppHost />
        </>
      );
    }

    // After sign-in, and on sign-out:
    await Sequenzy.identify(user.email);
    await Sequenzy.reset();

    Sequenzy.track("workout_completed", { minutes: 30 });

    // Push: the native APNs or FCM token, not the Expo push token.
    const { data } = await Notifications.getDevicePushTokenAsync();
    await Sequenzy.setPushToken(String(data));
    Notifications.addNotificationResponseReceivedListener((response) =>
      Sequenzy.handleNotificationResponse(response.notification.request.content.data)
    );
    ```

    With `@react-native-firebase/messaging`, pass `messaging().getToken()` on
    Android and `messaging().getAPNSToken()` on iOS to `setPushToken`, and
    `remoteMessage.data` to `handleNotificationResponse`. Sequenzy sends to
    iPhones through APNs, so iOS needs the APNs device token, not an FCM token.
    The SDK logs an error and skips registration if it gets the wrong kind.
  </Tab>

  <Tab title="Flutter">
    ```yaml theme={null}
    dependencies:
      sequenzy_flutter: ^0.1.0
      firebase_messaging: ^15.0.0
    ```

    ```dart theme={null}
    import 'package:sequenzy_flutter/sequenzy_flutter.dart';

    void main() {
      Sequenzy.configure(SequenzyConfig(
        publicKey: 'seq_pk_...',
        companyId: 'your-company-id',
        appId: 'com.example.app',
        platform: Platform.isIOS ? 'ios' : 'android',
        identityTokenProvider: (email) => myBackend.sequenzyToken(email),
      ));
      runApp(MaterialApp(
        builder: (context, child) => SequenzyInAppHost(child: child!),
        home: const HomePage(),
      ));
    }

    // After sign-in, and on sign-out:
    await Sequenzy.identify(user.email);
    await Sequenzy.reset();

    Sequenzy.track('workout_completed', {'minutes': 30});

    // Push: Sequenzy sends to iPhones through APNs, so iOS needs the APNs
    // device token. getToken() returns an FCM token there.
    final messaging = FirebaseMessaging.instance;
    final token = Platform.isIOS ? await messaging.getAPNSToken() : await messaging.getToken();
    if (token != null) await Sequenzy.setPushToken(token);
    FirebaseMessaging.onMessageOpenedApp.listen((message) => Sequenzy.handleNotificationOpened(message.data));
    ```

    Pass the notification's data to `handleNotificationOpened` from whichever
    tap handler your app uses for iOS notifications.
  </Tab>
</Tabs>

## Events

`track(name, properties)` records a custom event on the signed-in contact.
Events start [sequences](./sequences) with an **Event received** trigger, and
their properties work in merge tags such as `{{event.minutes}}`.

* Names are up to 100 characters: letters, digits, spaces and `_ . : - /`.
* Properties are a JSON object: up to 50 keys, nested at most 4 levels, 8 KB in
  total.
* Built-in event names (`saas.purchase`, `ecommerce.*`, `email.*`, `push.*`,
  `in_app.*`, `contact.*`, `tag.*`, `sms.*`, `web.*`, `poll.*` and the
  `sequence_*` events) are rejected, so a device cannot fake a purchase. Send
  those from your backend with
  [Track Event](../api-reference/subscribers/events/trigger).

Events tracked before `identify` wait on the device and are sent once the user
signs in. Events with properties that are not JSON (for example `NaN` or a date
object) or larger than 8 KB are dropped when you call `track`, with an error
in the SDK log. Event times come from the device clock, so a device more than 5
minutes fast has its events rejected. The SDK keeps up to 100 events, drops events older than 24 hours,
sends them in batches of 20 and retries with backoff when the device is
offline. Every event has an ID, so a retry never records it twice.

### Screens

`screen(name, properties)` records the built-in **Screen Viewed** event
(`app.screen_viewed`) with `screen` and your properties. Use it for screens you
want to segment or start sequences on, such as pricing or checkout. Custom
events cannot use the `app.` prefix.

### Products and carts

The same storefront events as the web SDK, so browse and cart abandonment work
in apps:

| Method | Records |
| - | - |
| `viewedProduct(product)` | Product viewed (starts browse abandonment) |
| `addedToCart(line)` | Added to cart |
| `removedFromCart(line)` | Removed from cart |
| `viewedCart(cart)` | The whole cart, for cart abandonment |
| `viewedCollection(collection)` | Collection viewed |
| `searched(query)` | Search submitted |

A product needs `providerProductId` (your product ID); `title`, `imageUrl`,
`url`, `price` or `priceCents` and `currency` make abandonment emails richer.
These events wait in the same queue as `track` and are sent once the user is
signed in.

## Profile

`setProfile` sets the signed-in contact's first name, last name and custom
attributes. Attributes merge into the ones the contact already has; pass null
for a name or an attribute to clear it. Like events, changes wait on the
device until the user is identified and are retried when the device is
offline; later calls merge into the pending change.

```ts theme={null}
Sequenzy.setProfile({ firstName: "Jane", attributes: { goal: "strength", level: 3 } });
```

Platform-owned values are rejected: `email`, `firstName` and `lastName` as
attribute names, Stripe billing attributes such as `mrr` and
`stripeCustomerId`, and `internal`. A user can only change their own contact,
but they can change it, so set anything that grants access or money (plan,
credits) from your backend instead.

## Signups

For people who are not signed in, such as a newsletter or waitlist screen,
`subscribe` adds an email through a published Sequenzy
[signup form](../widgets/signup-form): the form's lists, tags, duplicate
handling and double opt-in apply, as they do on a website. It does not
identify the device.

```ts theme={null}
const result = await Sequenzy.subscribe({ formId: "frm_123", email, firstName });
if (!result.ok) showError(result.error);
```

Forms with bot protection send app signups a confirmation email, because an
app cannot run the browser check. Signups are rate limited per IP address and
per email address.

## Push notifications

Upload your APNs key and Firebase service account first, as described in
[Push notifications](./push-notifications#ios-and-android-apps). Registering a
device before then works, and it starts receiving push once the credentials are
in place.

The SDK registers the token when it changes, after `identify` (which links the
device to the contact) and at least once a day. On iOS the Notification Service
Extension reports deliveries for pushes with an image; pushes without one are
counted when they are opened. `reset()` unregisters it, so a
shared device stops receiving the previous user's notifications. When the user
taps a notification, the SDK reports the open and opens its link or deep link.

## In-app messages and cards

Every SDK draws messages to the same design as the preview in the step
composer:

* **Modal**: a sheet that rises from the bottom (swipe down or tap outside to
  close), or a centered dialog. On tablets a sheet is drawn as a dialog.
* **Banner**: a floating card under the status bar or above the home
  indicator that does not block the app; swipe it toward its edge to close it.
* **Full screen**: the whole screen, with buttons at the bottom.
* An image sits above the text, or behind it with the text on a dark gradient.
* The first link button is filled with your button color; a second button is
  plain text.
* Animations follow the system's Reduce Motion setting, and text follows the
  system text size up to 1.4×.

After `identify`, and whenever the app comes to the foreground, the SDK fetches
open messages and shows them one at a time, styled with the colors, corners and
font from the step's **Design** tab. While the app stays open it checks again
every minute, so a message started by something the user just did shows up
without leaving the app. Shown, clicked and dismissed messages are reported for
the step's stats.

To decide yourself when a message may appear, return `false` from the SDK's
`shouldShow` callback; the message waits for the next check. To show messages
with your own UI, turn off automatic display and use `fetchMessages()` with
`report()`.

Content cards are for an inbox or feed screen. Each SDK returns them with
`fetchCards()` (pinned first). React Native (`useInAppCards` and `InAppCard`)
and Flutter (`InAppCardsController` and `InAppCardView`) include a ready-made
card view that reports clicks and dismissals; call `markRead` when a card
scrolls into view. On iOS and Android, draw cards in your own list and report
`read`, `clicked` and `dismissed` with `report()`.

On iOS and Android the SDK uses the system font unless the step's font is
installed in your app. In React Native, pass `fonts` to `SequenzyInAppHost` and
`InAppCard` to map brand font names to fonts your app bundles.

## Client endpoints

The SDKs call these endpoints. You only need them to build your own client.
Every call sends the publishable key as `x-sequenzy-key` (or `publicKey` in the
body) and the app ID as `x-sequenzy-app-id`, and identified calls add `email`
and `identityToken` to the body.

| Endpoint | Body | Returns |
| - | - | - |
| `POST /api/v1/push/app-devices` | `companyId`, `platform` (`ios` or `android`), `token`, optional `anonymousId`, `email` plus `identityToken`, `appVersion`, `osVersion`, `deviceName`, `locale`, `timezone` | `deviceId`, `identified`, `pushConfigured`; 201 when new, 200 when updated |
| `POST /api/v1/push/app-devices/remove` | `companyId`, `platform`, `token` | `removed` |
| `POST /api/v1/app-events` | `companyId`, `email`, `identityToken`, `events`: up to 20 of `{ type?, name, eventId, properties?, occurredAt? }` | 202 with `accepted` and `rejected` (`index`, `eventId`, `error`) |
| `POST /api/v1/app-attributes` | `companyId`, `email`, `identityToken`, any of `firstName`, `lastName` (null clears) and `attributes` | `success`; 404 `contact_not_found` when the contact does not exist |
| `POST /api/v1/app-subscribe` | `companyId`, `formId`, `email`, optional `firstName`, `lastName`, `attributes` | `success`; 404 when the form is not published in this workspace |
| `POST /api/v1/in-app/feed` | `companyId`, `email`, `identityToken`, optional `kind` and `limit` | `messages` |
| `POST /api/v1/in-app/feed/events` | `companyId`, `email`, `identityToken`, `messageId`, `type` | `success` |
| `POST` to a push's `eventsUrl` | `pushSendId`, `deviceId`, `token` (the push's `clickToken`), `type` (`delivered` or `clicked`) | `recorded` |

Registering with an email links the device to that contact, creating it if
needed; without one the device stays anonymous and keeps any link it already
had. Possessing the push token is what authorizes removing it.

An event's `type` is `custom` (the default), `screen` (recorded as
`app.screen_viewed`; `properties.screen` is required) or `commerce` (`name` is
`product_viewed`, `product_added_to_cart`, `product_removed_from_cart`,
`cart_viewed`, `collection_viewed` or `search_submitted`, and `properties`
carries `product`, `cartLine`, `cart`, `collection` or `searchQuery` as the web
SDK sends them).

`/api/v1/app-events` judges events one by one: a `202` lists the events it
rejected (a built-in name, an `occurredAt` more than 24 hours old or in the
future, invalid properties) and records the rest. A client should drop the
whole batch after a `202` and retry it, with the same event IDs, after a
network error or a `408`, `425`, `429` or `5xx`.

| Status | Meaning |
| - | - |
| 400 | Missing `companyId`, `platform` or `token`, an empty or oversized `events` array, or a body that is not JSON |
| 401 | Missing or invalid publishable key, or a key for another workspace |
| 403 | The app ID or origin is not allowed for the key, or the identity token is missing, expired or for another email (`code: "invalid_web_tracking_identity"`) |
| 413 | The body is too large |
| 429 | Too many requests from this IP address |
| 503 | Events could not be queued; retry |

## Troubleshooting

* **403 "App is not allowed for this web tracking key"**: add the app's bundle
  ID or package name under **Allowed app IDs**. Changes apply within about a
  minute.
* **Events or the feed do nothing after sign-in**: the SDK has no identity
  token. Pass one to `identify` or set an identity token provider; with debug
  logging on, the SDK says which is missing.
* **Device registered but no push arrives**: the registration response has
  `pushConfigured: false` until the platform's credentials are uploaded. On
  iOS, development builds need the **Sandbox** APNs environment.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.