Sign-in limits, billing and codes
Sign-in limits on a phone, sign out everywhere, billing, and the codes a mobile build meets.
Sign-in limits
Sign-in on a phone uses the same two calls as on the web: sendEmailCode,
then verifyEmailCode.
Once a sender domain is verified on the Domains page (the same setup as for a web app), the code comes from that domain's account address. Until then, it comes from Gemmein's shared address as <App name> (via Gemmein). No other email leaves from Gemmein's shared address.
How many sign-in emails your app sends in a day is never capped. Two abuse limits
apply: 3 per address and 20 per IP per app (an IPv6 address counts by its /64), each per 15 minutes,
answered 429 with resetAt. The Usage & billing page shows
the day's and the month's sign-in emails, and Gemmein sends you a
signin_surge alert when a day runs far past your app's own normal.
Sign out everywhere
Sign out everywhere is the fix for a stolen phone. You run it from the
dashboard on that person's page (Manage → Sign out everywhere), behind a confirm and an
emailed step-up code; the app cannot run it. The phone's next request then returns
401 auth_expired.
g.auth.logout() ends this device only. It is idempotent: a session you
have already ended resolves without throwing, because the session being gone is what
logout asked for.
Billing
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, or an apiUrl pointing at nothing. The message names the host, and err.cause carries the transport's own error | Handle it apart from a refusal. "The server said no" and "the server was never reached" are different problems, and the second must leave the app usable. A call cancelled with { signal } stays an AbortError and is not this code |
upload_input_unsupported | 0 (client-side) | @gemmein/sdk/expo: upload() was given 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 given to the device secure store, which iOS refuses with a native throw the app cannot read | Store the token, not the session payload. A Gemmein session token is two orders of magnitude smaller |
secure_store_unavailable | 0 (client-side) | The token store could not keep the session, so sign-in throws instead of handing back a session the app will lose. All three of the SDK's stores raise it:
| In Swift, -34018 is an unsigned build (a simulator build, for example) with no keychain access group: sign the app (ad-hoc is enough). The session itself is valid, so an app that would rather keep running 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 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, EPUB, audio and video), so a text/plain blob
is refused 415 unsupported_file_type whatever its name says.
Thanks for telling us.
Thanks for telling us.
Last updated