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 token — currentUser().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.
| Code | Status | Meaning · what to do |
|---|---|---|
network_unreachable | 0 (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_unsupported | 0 (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_large | 0 (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_unavailable | 0 (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 |
denied | 401 | A 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_configured | 409 | No 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.