Customer SDK (iOS, Android, React Native)

The customer SDK puts the same conversation and the same in-app content your website visitors get into your own mobile app. Chat rides the bot's existing web channel, so operators answer from the same inbox; in-app content is the same document as a website popup, targeted to mobile.

Read this before you plan a release. Registering and revoking a device push token works end to end, but nothing in the product sends a push to an SDK device: there is no APNs or FCM sender for customer devices. The SDK reports its push capability as disabled and will keep doing so until a sender exists. Everything below about chat, content, identity and the offline queue is live; mobile push is not.

What the SDK does

  • Bootstraps an installation — the app registers once, gets a durable installation token, and stores it in the Keychain or the Android Keystore.
  • Recognizes the user — while anonymous it is one visitor; after identify it is merged into the identified person and shares their history everywhere else in MyBot.
  • Queues work offline — events, profile properties, typing and push-token operations are recorded locally and uploaded in ordered batches when there is a network.
  • Chats — history, sending, attachments, unread count and a read cursor, over the same dialog operators already use.
  • Streams in realtime — a WebSocket for new, edited and deleted messages, authenticated by a single-use ticket.
  • Renders in-app content — the popup documents from Content studio, with impression, action and conversion receipts.

Platforms: iOS (Swift, iOS 15+, with an Objective-C facade and an optional UI target), Android (Kotlin, minSdk 24, with a Java facade) and React Native (TypeScript, over the two native SDKs). All three implement one specification and are tested against one set of golden fixtures — see The contract.

Enable the project

The SDK is set up per bot, on its own settings screen in the dashboard. Enabling asks for the apps that are allowed to use it and returns two credentials.

App allowlists. You list the iOS bundle ids and the Android package names of your apps (at least one is required to create the project, at most 64 in each list). An app that is not on the list is refused with exactly the same answer as an unknown project key, so a probe cannot tell "wrong key" from "right key, wrong app".

The project key looks like sdk1_eu_<24 hex>_<32 hex>. It is public, like the widget's install key: it goes into your app binary, it is safe there, and it cannot read conversations or change settings. The region inside it exists so the dashboard can show which deployment a project belongs to. The SDK forwards it verbatim and never parses it.

The identity secret is a different thing entirely and is never shipped in an app. It is shown exactly once — when you enable the project, and again each time you rotate it. Copy it into your own backend's secret storage at that moment; the dashboard and the API will never show it again.

Rotating the identity secret mints a new one and keeps the previous one working for 24 hours, so you have a deploy cycle to roll it out. Nothing already installed is invalidated by a rotation: the project key does not change, and devices keep working.

Revoking a device. The installation list shows platform, app id, SDK version, locale, timezone, whether it is identified and when it was last seen (up to 200 recent entries). Deleting an entry disables that installation's authentication immediately.

A deployment note. Creating a project requires the deployment-wide secret CUSTOMER_SDK_KEY_SECRET, which signs project keys. A production cell refuses to start without it. On a deployment where it is missing, enabling answers 503 while everything else keeps working — that is a configuration gap, not a broken feature. Rotating an identity secret does not use it and keeps working either way.

Install and start the client

The SDK sources live in the mobile repository under customer-sdk/: the Swift package MyBotSDK (customer-sdk/ios), the Gradle module mybot-sdk in the namespace dev.gelfand.mybot.sdk (customer-sdk/android), and the npm package @mybot/customer-sdk-react-native (customer-sdk/react-native).

Starting the client takes the project key, the base URL of your MyBot deployment and your app identifier. There is one host per app build; the SDK never routes to a different host based on the key's region.

let client = try await MyBotClient.start(
    configuration: Configuration(
        projectKey: "sdk1_eu_…",
        baseURL: URL(string: "https://api.getmybot.dev")!,
        appIdentifier: "dev.getmybot.example",
        sdkVersion: "1.0.0"
    )
)
val client = MyBotClient.start(
    context,
    MyBotConfiguration(
        projectKey = "sdk1_eu_…",
        baseUrl = "https://api.getmybot.dev",
    ),
)
const client = await MyBotClient.start({
  projectKey: "sdk1_eu_…",
  baseURL: "https://api.getmybot.dev",
  appIdentifier: "dev.getmybot.example",
});

The first start registers the installation and stores its token; every later start resumes it. The token is returned once — if it is ever lost or suspected leaked, the SDK refreshes it in place without changing the identity.

Identity: your backend signs, the device does not

A device may never claim who its user is. Identification is an assertion signed by your own backend with the identity secret, and the app only forwards the opaque result:

  1. Your app asks your backend "who am I".
  2. Your backend, which already authenticated the user, mints a short-lived JWT with the identity secret.
  3. Your app passes that string to the SDK's identify. The SDK does not decode it.

The server enforces the assertion strictly, and any deviation rejects the whole assertion:

{
  "alg": "HS256",
  "project_key": "sdk1_eu_…",
  "aud": "mybot-customer-sdk",
  "jti": "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
  "iat": 1730000000,
  "exp": 1730000120,
  "user_id": "acct_10293",
  "properties": { "plan": "pro", "role": "admin" }
}
  • Algorithm exactly HS256; audience exactly mybot-customer-sdk; iss, sub and nbf must be absent.
  • jti is a canonical UUIDv4; iat must not be in the future; the lifetime exp - iat must be at most 300 seconds.
  • user_id is your own opaque stable id (1–256 bytes) — never a raw email or phone typed into a form on the device.
  • properties is optional, at most 16 entries, and limited to the closed vocabulary account_id, plan, role, locale, with string values ≤256 bytes.

Identifying again with the same user is idempotent; identifying with a different user re-merges to that person. No raw contact data ever crosses this API — the same closed vocabulary applies to the profile route and to queued property operations.

Sign-out raises the identity epoch

Every installation carries an epoch, an integer that starts at 1 and increases by one on every sign-out. It is checked on every single request, with no grace window, and this is what makes a signed-out device safe on a shared phone:

  • queued operations from the previous epoch are dropped and can never be admitted again — the client discards them, and the server would reject the whole batch anyway;
  • the local sequence counter restarts at 1;
  • any open realtime socket is closed at once and no ticket minted before the sign-out may be redeemed;
  • in-flight uploads carrying the old epoch are cancelled;
  • push tokens registered under the previous epoch are revoked by the server itself, in the same transaction that raises the epoch — the app does not have to remember to revoke them on logout;
  • caches keyed by the previous person (content assignments, unread badge, cached profile fields) are cleared.

Realtime deserves a note, because a WebSocket handshake cannot carry the epoch header a normal request carries. The SDK mints a single-use ticket over HTTPS, and at handshake time the server re-reads the installation's epoch live from the database and compares it with the epoch captured when the ticket was minted. A sign-out that lands in between refuses the upgrade even though the ticket itself is unused. That is precisely why a client that has just signed out cannot land in the previous user's conversation.

Refreshing the token is the quiet sibling: same epoch, same person, only the bearer token rotates, and none of the effects above apply.

The offline queue

Operations recorded while the app is offline are uploaded as ordered batches. Each batch carries a client-generated id, the epoch, and a contiguous run of sequence numbers.

OperationWhat it does
track_eventrecords a product event (name ^[a-z][a-z0-9_.]{0,63}$ in practice — the stricter of two validators wins)
set_propertiessets profile properties from the closed vocabulary account_id, plan, role, locale
register_push_tokenstores an APNs or FCM token, encrypted (sandbox or production)
revoke_push_tokenrevokes one; revoking an unknown token is a silent no-op, never an error
notification_openedbest-effort analytics for an opened notification
in_app_impression, in_app_actionreceipts for one in-app content assignment
typingtyping indication in the chat

Server-side rules:

  • 1–100 operations per batch, with the sequence numbers exactly continuing where the last accepted batch stopped. A gap or an overlap rejects the whole batch.
  • A batch id already accepted for this installation and epoch answers "duplicate" without re-running anything, so a retry after a lost response is safe.
  • A wrong epoch rejects the whole batch; everything else is reported per operation with a closed reason code, so one bad property never discards a whole batch of good events.

Local bounds are the same on all three platforms, so a phone left offline for weeks cannot grow without limit. The oldest pending operations are evicted first:

  • 2000 queued operations per installation;
  • 2 MiB of serialized queue storage;
  • 7 days of age, regardless of the other two.

An operation that is currently being uploaded is never evicted. Each eviction fires a diagnostic callback with the local id and the reason (count, bytes or age) — never the operation's contents. Failed uploads back off from a 30-second base, doubling to at most 240 seconds, with full jitter; an authorization failure is never retried, because it is a fence, not a hiccup.

Chat

Chat is the bot's web channel seen from a phone: the same messages, the same operator inbox, the same automation triggers on send.

  • History is paged forward from a cursor, up to 50 messages per page. direction is relative to the customer: in is from the operator or bot.
  • Sending takes a client message id and answers "queued". That means accepted for delivery, not delivered; actual state arrives over realtime or on the next history read. Resending the same id with the same text is a no-op; resending it with different text is a conflict and means a bug in the app.
  • Attachments are uploaded as a single file, up to 10 MiB, deduplicated by content hash within the bot, and come back as a ready-to-use URL.
  • Unread is counted per installation against its own read cursor, entirely separate from any operator's read state, and the cursor only ever moves forward.
  • Realtime streams new, edited and deleted messages plus typing. On connect the server drains what was missed while offline (up to 50 frames) and then streams live; there is no client-supplied reconnect cursor.

If the bot's owner never connected a web channel, every chat route answers "chat is not available for this bot". Surface that as a disabled feature, not as a transient error.

In-app content

GET /sdk/v1/content returns already-assigned items: kind, priority, delay, visit and frequency rules, blocks and appearance. An empty list is a normal answer.

Targeting decides who sees what. A popup whose device targeting excludes mobile is never served to the SDK; a popup with no device targeting at all counts as "all devices" and is. Variant assignment is deterministic per person, so an anonymous user keeps seeing the same variant until identify or a sign-out re-seeds it.

Receipts are impression, action and conversion. A message the user swipes away is a client-side terminal state with no server event — exactly like a dismissed website popup.

Mobile push tokens

register_push_token and revoke_push_token are fully functional: the token is stored encrypted with a lookup digest, revoking an unknown token stays silent so it cannot be used as an oracle, and both participate in export and erasure.

They still do not lead to a delivered notification. There is no sender for customer devices anywhere in the product; the dashboard's operator push is a different system with different storage, different credentials and a different audience. The SDK's config therefore reports push as disabled, and that value is pinned by the contract fixtures on purpose so nobody "fixes" it before a sender exists. One consequence to plan for: a signed-out device keeps its registered token, because tokens are not fenced by the epoch — revoke it from your app when your user logs out.

Errors

Every /sdk/v1 error is a single-field JSON envelope, and the SDK classifies by status:

StatusMeaningWhat the SDK does
400bad local inputnever retried, surfaced as a client bug
401authorization fence: stale epoch, revoked token, unknown project, spent ticketnever retried with the same credential
404unknown or foreign content assignmentdropped as permanently invalid
409chat unavailable for this bot, or an idempotency conflictsurfaced, not retried
413attachment over 10 MiBsurfaced before any retry
500, 503transientretried with backoff on idempotent operations

Privacy and diagnostics

No SDK endpoint returns raw contact data, and the profile vocabulary is closed end to end. Diagnostics are off by default; when enabled they carry a closed event code, a timestamp and redacted numeric metadata only — never a token, an assertion, a push token, a URL or any message, property or content body. SDK installations fall into the sdk_diagnostics retention category with a default retention of 180 days, and participate in subscriber merge, export and erasure.

The contract

The SDK wire surface is deliberately a separate contract from the REST API: a personal access token is never accepted on /sdk/v1, and an installation token is never accepted on /api. Its OpenAPI document is published unauthenticated by the server itself:

GET https://your-deployment/sdk/openapi.yaml

Beyond the routes, the behaviour every client must implement identically — state machines, ordering, queue bounds, backoff, error classification and the places where the specification deliberately follows the server — is written down in customer-sdk/contract/behavior.md in the mobile repository, alongside a machine-extracted snapshot of the server's own structs, enums, limits and patterns and 75 golden fixtures. A script re-derives that snapshot from a backend checkout and fails on undeclared drift, and every platform's test suite decodes the same fixtures, so a change on the server cannot quietly desynchronize three clients.

API and MCP

Seven owner routes manage the project with a personal access token — see REST API and tokens. Reads need the customer_sdk:read scope and the bot's analytics right; writes need customer_sdk:write and the reactions right. MCP exposes two read-only tools and deliberately no enable, rotate or revoke tool — see MCP.

What's next

  • Site widget — the same conversation on your website.
  • Browser push — notifications for website visitors, which does have a sender.
  • Content studio — the documents in-app content renders.