guide

Mobile

A phone app is the same Gemmein contract as a browser app. The public key identifies the app, the session authorises the person, and verified domains are a browser control — an app sends no Origin, so they are not checked for one. Ordinary API authentication does not establish app integrity: the app key ships inside the binary, so build as though any request could come from anywhere and let the session and the server's rules decide, exactly as on the web. Two entry points, one contract: @gemmein/sdk/expo for Expo and React Native, and the GemmeinSwift package for Apple platforms.

What it is. The same Gemmein contract from an Expo app (@gemmein/sdk/expo — the same npm package, a second entry point) or a Swift app (GemmeinSwift, iOS 17+ and macOS 14+, the same method names).

Does. Sign-in by email code; your app's collections, private records included; sealed files and files.link; credits; AI tools; the store account tokencurrentUser().storeAccountToken, the name you hand RevenueCat as its app user id; sign out everywhere, the remedy for a stolen phone, which you run from the console on that person's page; secure persistence — the iOS Keychain and the Android Keystore through expo-secure-store, the Keychain through KeychainTokenStore in Swift — so the app relaunches still signed in.

Does not. Does not offer Google or Apple sign-in (email codes are the only door, on every platform); does not hold store billing (Gemmein never talks to the App Store or Play — a store purchase reaches it through RevenueCat's webhook into a relay); does not broadcast (one event acts on one person).

Needs something else when. The person buys a store product → RevenueCat and the three relay templates below; they buy on the web, or the goods are not the store's → Stripe, the built-in path; your app needs more sign-in codes than the two bounds allow → those are abuse bounds, never a growth ceiling: 3 per address and 20 per IP per app, each per 15 minutes, and how many sign-in emails an app sends in a day is never capped; the store cannot keep the session (expo-secure-store not installed, an unsigned simulator build with no keychain access group, or a browser refusing the localStorage write) → sign-in throws secure_store_unavailable rather than reporting a session the app will lose; catch it and rebuild the client with a MemoryTokenStore, which keeps the session for the life of the process.

Example. An Expo app that signs a person in, reads a private collection, and hands RevenueCat the store account token; the same three calls in Swift.

Expo and React Native

@gemmein/sdk/expo is the same package as @gemmein/sdk, with a second entry point that fills in the four seams a phone has: where the session lives, how "is the app in front of the person?" is answered, which fetch carries the bytes, and what the client tag says. Nothing is imported from Expo or React Native at module load, so the entry resolves in Node too. The peers install in the app, not in your monorepo: npx expo install expo-secure-store expo-file-system.

import { createExpoGemmein } from "@gemmein/sdk/expo"
import { AppState, Platform } from "react-native"

export const g = createExpoGemmein({ appKey: "pk_live_..." }, { AppState, Platform })

await g.auth.sendEmailCode(email)
await g.auth.verifyEmailCode({ email, code })
const { records } = await g.collection("notes").list({ limit: 20 })

const me = await g.auth.currentUser()
if (me.authenticated && me.storeAccountToken) {
  // RevenueCat must know the buyer by this name
  await Purchases.configure({ apiKey: RC_KEY, appUserID: me.storeAccountToken })
  // already configured? Purchases.logIn(me.storeAccountToken)
}

The second argument is the modules, and every key in it is optional: { SecureStore, AppState, Platform, FileSystem, fetch }. Pass none and the entry resolves expo-secure-store, react-native, expo-file-system and expo/fetch lazily, on the first call that needs one; pass your own — an app's static imports, a test's fakes — and they win, with no dynamic resolution at all. Pass Platform and the x-client-info header reads expo-ios, expo-android or expo-web; without it it reads expo. Either way it is a report the app makes about itself, never a proof, and the usage ledger stores it as one. Your own tokenStore, visibility, fetch or platform passed in the first argument wins over the entry's defaults.

expo/fetch is the fetch this entry hands the client, because React Native's own fetch is XHR-backed and cannot give a streaming body — which is what g.ai.chat({ stream: true }) and g.ai.run(…, { stream: true }) return. One consequence reaches upload(): Expo's FormData refuses a bare picker part, so @gemmein/sdk/expo turns a picker's { uri, name, type, size } into an expo-file-system File before the bytes are sent. Install expo-file-system for that shape; without it, that one call is refused upload_input_unsupported. A Blob or a File needs neither.

Swift

GemmeinSwift is the same HTTP contract and the same method names, in Swift idiom, for iOS 17+ and macOS 14+, with no dependencies. In Xcode: File → Add Package Dependencies, or in a Package.swift: .package(url: "https://github.com/gemmeinhq/gemmein-swift.git", from: "0.10.0"). The session lives in the Keychain by default, so a relaunch keeps the person signed in; a Keychain that refuses the write throws secure_store_unavailable rather than handing back a session the phone will lose. One session per person holds on every surface: a sign-in on the phone signs the same person out of the browser, and the other way round.

import GemmeinSwift

let g = try Gemmein(appKey: "pk_live_...")   // KeychainTokenStore by default

try await g.auth.sendEmailCode(email)
_ = try await g.auth.verifyEmailCode(email: email, code: code)
let page = try await g.collection("notes").list(ListOptions(limit: 20))

if let token = try await g.auth.currentUser().storeAccountToken {
    Purchases.configure(withAPIKey: rcKey, appUserID: token)
}

GemmeinServer ships in the package for Swift that runs on a server. It takes a secret key, and a secret key never belongs in an app bundle. Everything the package throws is a GemmeinError carrying the server's own code and sentence, plus resetAt on a rate limit and requires on a 403 entitlement_required — the same branching your web code does.

The store account token

currentUser() carries storeAccountToken: an opaque per-person name, minted on the first call and stable for the life of the person, correlated to nothing and gone when the person is erased. Hand a store that — RevenueCat's app user id, Apple's appAccountToken, Google's obfuscatedExternalAccountId — and never the person id or their address. A value inside a third party's ledger is outside the erasure cascade and outside your control. An engine that does not carry one leaves the field absent; read that as "not carried", never as "no account".

Store purchases: RevenueCat, then a relay

Gemmein never talks to the App Store or Play. A store purchase reaches it as a signed webhook from RevenueCat into a relay, which grants the product exactly as a Stripe purchase does. The Relays room offers three templates: the purchase below, and one each for EXPIRATION and CANCELLATION. It is three relays and not one because when matches one exact value per field, so an event-type set cannot be written. Give each relay its own URL and its own secret, and put that secret in RevenueCat's Authorization header field (shared_token reads the authorization header).

The purchase relay carries no when: a product names exactly one relay as the road it is sold on, so the three purchase events cannot be split across three relays. Its only guard is therefore the RevenueCat integration itself — narrow it to INITIAL_PURCHASE, RENEWAL and NON_RENEWING_PURCHASE and nothing else. On the Payments page, set the product's "How it's sold" to the purchase relay: that is what lets it grant a purchase.

{
  "name": "revenuecat-purchase",
  "trigger": {
    "kind": "receiver",
    "verify": { "scheme": "shared_token", "header": "authorization" },
    "map": {
      "event_id": "event.id",
      "event_type": "event.type",
      "person_token": "event.app_user_id",
      "product_id": "event.product_id",
      "store": "event.store",
      "transaction_id": "event.transaction_id"
    }
  },
  "actions": [
    { "type": "fulfil_product", "product": "Pro", "ref": "{{mapped.transaction_id}}" }
  ]
}

The two revoking relays map the same fields without transaction_id, and each has one action — { "type": "revoke_access", "entitlement": "access:pro" }, the entitlement being the key the purchase granted. That key is derived from the product's name the first time it is saved: access: plus the name lowercased, with every run of anything that is not a letter or a digit turned into one dash — a product named Pro Max carries access:pro-max. It is minted once, so a later rename keeps the key and nobody loses what they hold. Each revoking relay also carries its own filter: revenuecat-expiration has "when": { "event_type": "EXPIRATION" } and revenuecat-cancellation has "when": { "event_type": "CANCELLATION" }.

The filter is the relay's own guard, not a note about RevenueCat's console. A purchase authorises itself through the road a product names; a revoke authorises nothing. Without the when, one URL pasted into the wrong integration — or an integration widened later — would take a paying customer's access away on the very event that granted it. An event the filter does not match answers 200 and is not recorded.

A receiver map names the person one way: person_email (their address, which invites someone who has never signed in), person_id (a gemmein person id) or person_token (the store account token their app filed with a store). Two of them in one map is refused when the relay is saved. The store road uses person_token, so neither the person id nor the address ever enters RevenueCat's ledger.

The webhook arrives after the store's own confirmation. The purchase is complete on the device before Gemmein has heard about it, so the balance a phone reads back a moment later may still be the old one. Show the pack as arriving until the balance reads back — never as failed, and never grant the goods on the client.

Sign-in, and the two bounds

Sign-in on a phone is the same two calls as on the web: sendEmailCode, then verifyEmailCode. The code arrives from your own domain — from its account word — once a sender domain is verified on the Domains page, the same setup as for a web app; until then it is sent from Gemmein's shared address as <App name> (via Gemmein) — the one email Gemmein sends on your behalf, and the only one. There is no cap on how many sign-in emails your app may send in a day — growth is never capped. Two bounds exist, and they are abuse bounds: 3 per address and 20 per IP per app, each per 15 minutes, answered 429 with resetAt. Your Usage & billing page carries the day's and the month's sign-in emails, and Gemmein raises a signin_surge alert to you when a day runs far past your app's own normal.

Sign out everywhere is the remedy for a stolen phone, and it is the owner's, not the app's: run it from the console on that person's page (Manage → Sign out everywhere), behind a confirm and an emailed step-up code. The phone's next request then comes back 401 auth_expired. g.auth.logout() ends this device only, and is idempotent — a session the owner has already ended resolves rather than throwing, because the session being gone is what logout asked for.

What the bill counts

A person counts once a month, on their first signed-in request, on web and mobile alike. Anonymous use never counts, and development never bills. A phone that polls all day is the same one person as a browser tab that loads once.

Codes

Everything on the error table reaches a phone unchanged. These are the ones a mobile build meets that a browser build does not.

CodeStatusMeaning · what to do
network_unreachable0 (client-side)The request never reached Gemmein: no connection, an offline device, a host that does not resolve, an apiUrl pointing at nothing. The message names the host and err.cause carries the transport's own error. Branch on it separately from a refusal — "the server said no" and "the server was never reached" are different problems, and the second one must leave the app usable. A call cancelled with { signal } is not this; it stays an AbortError
upload_input_unsupported0 (client-side)@gemmein/sdk/expo: upload() was handed a picker's { uri } and expo-file-system is not installed, so there is nothing to read the bytes with. Run npx expo install expo-file-system, or pass a Blob or an expo-file-system File
token_too_large0 (client-side)A value over 2,048 bytes was handed to the device secure store, which iOS refuses with a native throw the app cannot read. A Gemmein session token is two orders of magnitude smaller: store the token, not the session payload
secure_store_unavailable0 (client-side)The token store could not keep the session, so sign-in throws rather than handing back a session the app will lose. All three of the SDK's stores raise it. In Expo, expo-secure-store is not installed or the device refused the write, and the device's own error is on err.cause; in Swift the Keychain refused, and the OSStatus is in the message — -34018 is an unsigned build with no keychain access group, so sign the app (ad-hoc is enough); in a browser, BrowserTokenStore raises it when localStorage.setItem is refused. Reads and clears stay lenient: an unreadable store means signed out, never a crash. The session is real, so an app that would rather run than stop catches this one code and rebuilds its client with a MemoryTokenStore, signed in until the process ends
denied401A private collection read or write with no signed-in person. Sign in first; the refusal is the server's, and the app is never the check
ai_not_configured409No provider key on this app and environment. Paste one on the AI tools page, and the same tool runs unchanged

Uploads are sniffed from the bytes when they are confirmed, against the allow-list on Data — images, PDF, ZIP and EPUB — so a text/plain blob is refused 415 unsupported_file_type whatever its name says.

Both rails

npx gemmein dev serves a phone exactly as it serves a browser. Point the app at it with apiUrl — an option on createExpoGemmein's first argument, beside appKey, and apiURL: on Swift's Gemmein(appKey:apiURL:):

export const g = createExpoGemmein(
  { appKey: "pk_test_...", apiUrl: "http://127.0.0.1:4545" },
  { AppState, Platform },
)

Two host facts decide which address goes in there. An iOS simulator shares the Mac's network stack and reaches the engine at http://127.0.0.1:<port>; an Android emulator reaches it at http://10.0.2.2:<port>, because 127.0.0.1 inside the emulator is the emulator. The presigned upload URL and the file link are re-addressed to the host the request came in on, so uploads and downloads follow the address the app used.

gemmein dev binds 127.0.0.1. A simulator and a current Android emulator both reach it there as it is. A real phone on the same wifi does not, and neither does a second computer — serve those with one flag:

npx gemmein dev --host 0.0.0.0

It is opt-in, one run at a time, and it prints its own warning: reachable from every device on your network, and the engine holds your app's data, its local server key and any AI provider key in your shell. The card also prints both phone addresses so you can copy the right one. Leave the flag off and the engine stays on 127.0.0.1, this machine only.