SDKs & integrations
Flutter SDK
Use orbitnest_studio_flutter in Flutter apps for auth (password, OTP, SMS, MFA, passkeys), database access, storage, realtime, edge functions and analytics.
orbitnest_studio_flutter is the client SDK for Flutter apps. It authenticates with your project's anon key. Once a user signs in, it also sends that user's access token, so row-level security applies to them. This page describes version 1.7.0, which needs Dart 3.0 or later and Flutter 3.10 or later. Version 1.7.0 isn't on pub.dev yet, so ^1.6.3 resolves to 1.6.3 until it is published. Behavior that is new in 1.7.0 is marked "from version 1.7.0".
Install
dependencies:
orbitnest_studio_flutter: ^1.6.3flutter pub getConfigure
The SDK reads its settings from a .env file that is bundled as an asset. Create one at your app root and register it in pubspec.yaml:
ORBITNEST_ANON_KEY=your_anon_key_here
ORBITNEST_DEBUG=falseflutter:
assets:
- .env| Variable | Default | Description |
|---|---|---|
ORBITNEST_ANON_KEY | Required, unless you pass anonKey in code | Your project's anon key. The SDK decodes the project slug from its project_slug claim. |
ORBITNEST_API_URL | https://api.orbitnest.io | Override for local development. http:// is accepted only for localhost, 127.0.0.1, ::1 and 10.0.2.2. Any other http:// URL falls back to the default with a warning. In debug builds on the Android emulator, localhost is rewritten to 10.0.2.2. |
ORBITNEST_API_TIMEOUT | 180000 | Connect, send and receive timeout in milliseconds |
ORBITNEST_DEBUG | false | Set to true for verbose SDK logs. Tokens and keys are redacted. |
If you pass the anon key in code, the .env asset is optional from version 1.7.0. You still call EnvConfig.initialize(), and the SDK uses the defaults for any variable it can't read. In 1.6.3, OrbitNestClient.create(anonKey:) crashed without a loaded .env, so keep the asset registered there.
Ship only the anon key
Anything bundled in an app can be extracted from the binary. The SDK only accepts the anon key. Never put a service-role key in a Flutter app. Copy the anon key from your project's API Keys page in Studio. See API keys & JWT.
Initialize
import 'package:flutter/material.dart';
import 'package:orbitnest_studio_flutter/orbitnest_studio_flutter.dart';
late final OrbitNestClient orbitnest;
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await EnvConfig.initialize(); // loads .env; pass envFileName: to use another file
orbitnest = OrbitNestClient.create(); // or OrbitNestClient.create(anonKey: '...')
runApp(const MyApp());
}OrbitNestClient.create({String? anonKey}) throws if EnvConfig.initialize() hasn't run, or if the key has no project_slug claim. Create one client for the whole app, and call dispose() if you tear it down.
| Member | What it does |
|---|---|
auth | Sign-up, sign-in, OTP, SMS, MFA, passkeys and session state |
database | Row reads and writes, raw SQL |
storage | storage.from(bucket) for upload, download, list, remove and public URLs |
realtime / channel(name) | Postgres changes, broadcast and presence |
functions / function(name, params:) | Invoke edge functions |
analytics | Batched event tracking |
onSessionExpired | A Stream<void> that fires when the server rejects the refresh token |
from(table) | Query builder. See Query builder. |
select, insert, update, delete, sql | Shortcuts for the database methods that return the rows directly |
signIn, signUp, signOut, currentUser(), currentSession() | Auth shortcuts |
projectSlug, baseUrl, anonKey | The resolved configuration |
How requests are authenticated
- Auth endpoints (sign-up, sign-in, OTP, refresh) are sent without a user token.
- Every other request carries
apikey: <anon key>, plusAuthorization: Bearer <access token>while a user is signed in, orBearer <anon key>when nobody is. RLS policies apply as the signed-in user, and edge functions receive them asctx.user. From version 1.7.0, analytics requests carryapikeytoo, and a401from analytics never touches the session. - Sessions are stored with
flutter_secure_storageand restored when the app starts. The SDK refreshes the access token when it has less than 5 minutes left. On a401it refreshes once and retries the request. - Session expiry. From version 1.7.0,
onSessionExpiredfires only when the server rejects the refresh token. These cases are returned to the caller as errors and keep the session: a401with nobody signed in, a401from an auth or analytics endpoint, a401or403on the retried request, and a refresh that fails because of the network, a timeout,408,429or a5xx. An app that starts offline keeps its stored session and refreshes on the next request. In 1.6.3, any of these could sign the user out. - Retries. Network errors and
408,429,500,502,503and504responses are retried up to 3 times, with exponential backoff from 500 ms up to 10 s. From version 1.7.0, a429that carries an error code (such asSMS_RATE_LIMITED) isn't retried, and uploads (FormDatabodies) are never retried.
Access tokens last 15 minutes and refresh tokens 7 days, unless the project's JWT settings change those defaults.
orbitnest.onSessionExpired.listen((_) {
// The session can't be recovered. Send the user to your sign-in screen.
});Authentication
Methods return plain maps and throw AuthException on failure. See Authentication for project settings such as SMTP, Twilio and token lifetimes. The SDK has no OAuth (social) sign-in methods.
Email and password
// Sign-up emails a code (6 digits by default)
await orbitnest.auth.signUp(
email: 'ada@example.com',
password: 'correct-horse-battery',
metadata: {'full_name': 'Ada'},
);
await orbitnest.auth.verifySignUp(email: 'ada@example.com', otp: '1234');
// Sign in
final res = await orbitnest.auth.signInWithPassword(email: email, password: password);
if (res['mfa_required'] == true) {
await orbitnest.auth.verifyMfa(challengeToken: res['challenge_token'], code: code);
}
print(orbitnest.auth.currentUser?.email);A successful sign-in returns { 'user': ..., 'session': ... }. When the account has a verified MFA factor, it returns { 'mfa_required': true, 'challenge_token': ..., 'factors': ... } and doesn't start a session.
Passwordless email OTP
await orbitnest.auth.signUpWithEmail('ada@example.com', metadata: {'full_name': 'Ada'});
await orbitnest.auth.verifySignUp(email: 'ada@example.com', otp: code); // optional: password:
await orbitnest.auth.signInWithEmail('ada@example.com');
await orbitnest.auth.verifySignIn(email: 'ada@example.com', otp: code);SMS
The project must have Twilio configured. See Authentication.
await orbitnest.auth.signInWithSms('+15555550123'); // sends a 6-digit code
await orbitnest.auth.verifySmsOtp(phone: '+15555550123', code: code); // creates the user on first sign-inEach phone number can request one code per minute and five per hour. Past that, signInWithSms throws an AuthException with code SMS_RATE_LIMITED. After otp_config.max_attempts wrong codes (3 by default), verifySmsOtp throws with code OTP_RATE_LIMITED. From version 1.7.0, verifySmsOtp also updates currentUser, isAuthenticated and onAuthStateChange, like a password sign-in.
MFA (TOTP)
final enroll = await orbitnest.auth.enrollMfaTotp(friendlyName: 'My phone');
// enroll: factor_id, secret, otpauth_url, qr_code
final done = await orbitnest.auth.verifyMfaEnrollment(factorId: enroll['factor_id'], code: code);
// done['recovery_codes'] is shown once| Method | Description |
|---|---|
enrollMfaTotp({friendlyName, code}) | Starts enrollment. Requires a signed-in user. |
verifyMfaEnrollment({factorId, code}) | Confirms enrollment and returns recovery_codes |
verifyMfa({challengeToken, code}) | Completes an MFA sign-in. code can be a TOTP code or a recovery code. |
listMfaFactors() | List<dynamic> of factors |
unenrollMfa({factorId, code}) | Removes a factor |
regenerateMfaRecoveryCodes({code}) | Returns { codes: [...] } once |
Once the user has a verified factor, enrollMfaTotp, unenrollMfa and regenerateMfaRecoveryCodes need code: a current TOTP code or a recovery code. The code parameter is new in version 1.7.0. Without it the API returns 401 with code MFA_REAUTH_REQUIRED, and too many wrong codes return 429.
Passkeys
Passkeys use the platform credential APIs (Credential Manager on Android, ASAuthorization on iOS) through the passkeys plugin. Your app must be set up for passkeys on each platform, as described in that plugin's documentation. Set your app domain in Studio and host the two .well-known files, as described in Passkey setup.
if (await orbitnest.auth.isPasskeySupported()) {
await orbitnest.auth.signUpWithPasskey(
email: 'ada@example.com',
userMetadata: {'full_name': 'Ada'}, // shown in the native "save passkey" sheet
);
await orbitnest.auth.signInWithPasskey(identifier: 'ada@example.com');
}
// Signed in:
final device = await orbitnest.auth.registerPasskey(deviceName: 'Pixel 9');
final devices = await orbitnest.auth.listPasskeys();
await orbitnest.auth.renamePasskey(deviceId: device.id, deviceName: 'Work phone');
await orbitnest.auth.revokePasskey(deviceId: device.id);Method reference
| Method | Description |
|---|---|
signUp({email, password, metadata}) | Sends a sign-up code |
signUpWithEmail(email, {metadata}) | Passwordless sign-up. Sends a code. |
verifySignUp({email, otp, password}) | Creates the user and starts a session |
signInWithPassword({email, password}) | Session, or an MFA challenge |
signInWithEmail(email) / verifySignIn({email, otp}) | Email OTP sign-in |
signInWithSms(phone) / verifySmsOtp({phone, code}) | SMS OTP sign-in |
resetPasswordForEmail(email) | Emails a reset code |
resetPassword({email, token, newPassword}) | Sets a new password with the emailed code. From version 1.7.0, returns {email, message}. The user then signs in with the new password. |
updateUser({email, password, currentPassword, metadata}) | Updates the signed-in user. metadata is stored in user_metadata. |
deleteUser() | From version 1.7.0. Deletes the signed-in user's account, then clears the local session. |
refreshSession() | Refreshes now. This normally happens automatically. From version 1.7.0, throws an AuthException (REFRESH_FAILED) when the refresh fails, instead of never completing. |
signOut() | Ends the session and clears secure storage |
getUser() | Future<User>. From version 1.7.0, throws an AuthException (UNAUTHENTICATED) when nobody is signed in, instead of never completing. |
verifyToken(token) | Asks the API whether a token is valid |
decodeToken(token) | Decodes a JWT locally, without verifying it |
Changing email or password with updateUser needs currentPassword (new in 1.7.0). Accounts without a password need a session signed in within the last 10 minutes instead. Otherwise the API returns 401 with code REAUTH_REQUIRED. A new email only takes effect after the user enters the code sent to it. Apply it with await orbitnest.auth.verifyEmailChange(code). See Change the email address.
Auth state
| Member | Description |
|---|---|
currentUser | User?. null when nobody is signed in. Stays set while other auth operations are in flight. |
currentSession | Session? with accessToken, refreshToken, expiresIn and user |
isAuthenticated | bool |
onAuthStateChange | Stream<AuthState> |
AuthState is a sealed class with these subclasses:
| State | Fields |
|---|---|
AuthInitialState | None |
AuthLoadingState | None |
AuthAuthenticatedState | user, session |
AuthUnauthenticatedState | None |
AuthMfaRequiredState | challengeToken, factors |
AuthOtpSentState | email, message, type (signup or signin) |
AuthPasswordResetSentState | email, message |
AuthUserUpdatedState | user, message |
AuthPasskeysListedState | devices |
AuthPasskeyRegisteredState | device |
AuthPasskeyUpdatedState | deviceId, message |
AuthPasskeyRevokedState | deviceId |
AuthErrorState | message, code, details |
orbitnest.auth.onAuthStateChange.listen((state) {
switch (state) {
case AuthAuthenticatedState(:final user):
print('signed in as ${user.email}');
case AuthUnauthenticatedState():
print('signed out');
default:
break;
}
});Database
orbitnest.database reads and writes rows through the project's table API, and runs raw SQL. The client-level shortcuts (orbitnest.select, insert, update, delete, sql) call the same methods and return the rows directly.
Database API in 1.6.3
Version 1.7.0 rewrites the database API, and most of this section describes it. In 1.6.3, from(table) throws UnsupportedError, select() ignores filters, ordering, offset and columns, sql() can't take parameters, insertMany() fails with the anon key, updateMany() ignores its filters, and deleteMany() treats every filter value as an id. Don't use updateMany() or deleteMany() on 1.6.3.
From version 1.7.0, every database method returns its own future, so you can run calls in parallel, for example with Future.wait. Methods that the API can't do throw UnsupportedError or ArgumentError before sending anything, instead of returning the wrong rows.
Read rows
final res = await orbitnest.database.select(
'posts',
columns: 'id, title',
filters: {'status': 'published'},
orderBy: ['created_at.desc'],
limit: 20,
);
for (final row in res.data) {
print(row['title']);
}
print(res.count); // total row count reported by the APIselect returns a PostgrestResponse with data (List<Map<String, dynamic>>), count and status.
filtersare equality conditions combined withAND. Anullvalue meansIS NULL.orderBytakes one entry, such as'created_at.desc'. The API sorts by one column only.limitandoffsetpage through the rows. A page holds at most 100 rows.columnsis a plain column list, applied in the client. The API always returns every column.
While a user is signed in, reads run under that user's RLS policies.
Query builder
orbitnest.from(table) (or database.from(table)) builds the same requests with a chain:
final res = await orbitnest
.from('posts')
.select('id, title')
.eq('status', 'published')
.gte('created_at', '2026-01-01T00:00:00Z')
.order('created_at', ascending: false)
.range(0, 19)
.execute();
await orbitnest.from('posts').eq('id', postId).update({'title': 'Edited'});
await orbitnest.from('posts').eq('id', postId).delete();| Supported | Throws UnsupportedError |
|---|---|
eq, neq, gt, gte, lt, lte, like, ilike, inFilter, isFilter, isNull, match, not(column, 'eq', value) | or, and, not with any other operator, isNotNull, contains, containedBy, overlaps, range* filters, textSearch |
One order() column, limit(), range(), single() and maybeSingle() (both set a limit of 1) | A second order() column, aliased or embedded select columns |
insert(map) or insert(list), update(values), delete() | upsert(), and insert with upsert: true or ignoreDuplicates: true |
- Filter values may contain
:, such as timestamps and URLs.inFiltervalues can't contain,, andisFilteraccepts onlynull,trueorfalse. update()anddelete()take equality filters only (eqormatch), and refuse to run without one, so they can never touch every row.execute()runs a select. Filters must be added before the terminal call (execute,insert,update,delete).
Raw SQL
final res = await orbitnest.database.sql(
r'SELECT id, title FROM posts WHERE author_id = $1 ORDER BY created_at DESC LIMIT 10',
parameters: [authorId],
);
print(res.data); // the result rows; res.count is the number of rows affectedFrom version 1.7.0, sql() binds parameters to $1, $2, … and returns the result rows. vectorSearch() uses it, so it works again too. Statements sent with the anon key, with or without a user session, must start with SELECT, SHOW, DESCRIBE or INSERT, and a call can hold only one statement. Raw SQL doesn't run as the signed-in user, so use select() when a read must follow per-user policies.
Caution
Always pass user input through parameters. In 1.6.3 parameters aren't supported, so never build SQL strings from user input there.
Insert
final res = await orbitnest.database.insert('posts', {
'title': 'Hello',
'author_id': orbitnest.auth.currentUser!.id,
});
final created = res.data.first; // includes server defaults such as id and timestampsInserts work with or without a signed-in user. While a user is signed in, RLS policies are evaluated as that user.
insertMany(table, rows) and the builder's insert(list) send one request per row, so they aren't atomic: if one row fails, the rows before it stay inserted. The API has no upsert, so insert(..., upsert: true) throws UnsupportedError.
Update and delete
await orbitnest.database.update('posts', {'title': 'Edited'}, filters: {'id': postId});
await orbitnest.database.delete('posts', filters: {'author_id': userId, 'status': 'draft'});update, updateMany, delete and deleteMany change every row whose columns equal all of filters. filters is required, and an empty map or a null value throws ArgumentError. The result's count is the number of rows changed, and data is empty. These calls need a signed-in user, and RLS applies. Without a session the API returns 401.
Storage
final avatars = orbitnest.storage.from('avatars');
await avatars.upload(
path: 'users/42.png',
bytes: pngBytes,
contentType: 'image/png',
upsert: true,
);
final bytes = await avatars.download('users/42.png'); // Uint8List
final files = await avatars.list(prefix: 'users/');
await avatars.remove(['users/42.png']);
final url = avatars.getPublicUrl(
'users/42.png',
transform: const StorageTransform(width: 200, height: 200, format: 'webp', quality: 80, fit: 'cover'),
);| Method | Returns | Notes |
|---|---|---|
upload({path, bytes, contentType, upsert = false}) | Map (the stored file) | Needs a signed-in user. Maximum 50 MB. From version 1.7.0, uploads aren't retried. |
download(path) | Uint8List | |
list({prefix}) | List<dynamic> | |
remove(paths) | { deleted, errors } | Needs a signed-in user |
getPublicUrl(path, {transform}) | String | Built locally. Works only for public buckets. |
createBucket() | Map | Needs the service-role key, so it fails from an app |
Who can access what:
- Signed-in users can read every file in public buckets. In private buckets they can read, upload and remove only the files they uploaded.
- Without a session,
listanddownloadwork only in public buckets, anduploadandremovereturn403.
StorageTransform takes width and height (capped at 4000), format (webp, jpeg, png, avif), quality (1–100), and fit (cover, contain, fill, inside, outside).
From version 1.7.0, every storage request method throws a StorageException when the call fails. It extends OrbitNestException and carries the API's statusCode, code, message and the parsed error body. Earlier versions threw a Dio DioException that wrapped an OrbitNestException.
try {
await avatars.upload(path: 'users/42.png', bytes: pngBytes, contentType: 'image/png');
} on StorageException catch (e) {
if (e.code == 'QUOTA_EXCEEDED') {
// 402: e.body holds quota, plan, limit and usage
} else if (e.statusCode == 404) {
// bucket or file not found
} else if (e.statusCode == 403) {
// private bucket without a session, or a file another user uploaded
}
}See Storage.
Realtime
final channel = orbitnest.channel('room-42')
..onPostgresChanges(
event: PgEvent.insert,
table: 'messages',
filter: const RealtimeFilter(column: 'room_id', op: 'eq', value: 42),
callback: (e) => print('new message: ${e.newRow}'),
)
..onBroadcast(event: 'typing', callback: (b) => print(b.payload))
..onPresenceSync((state) => print('online: ${state.keys}'));
await channel.subscribe();
await channel.track({'name': 'Ada'});
await channel.send(event: 'typing', payload: {'name': 'Ada'});
await channel.unsubscribe();
await orbitnest.realtime.dispose(); // closes the socket| Member | Description |
|---|---|
onPostgresChanges({event, table, schema = 'public', filter, callback}) | event is a PgEvent: insert, update, delete or all. The callback receives a PgChangesPayload with op, schema, table, newRow, oldRow and commitTs. |
onBroadcast({event, callback}) | event: '*' matches every event. The callback receives a BroadcastPayload with event and payload. |
onPresenceSync, onPresenceJoin, onPresenceLeave | Roster callbacks |
track(state) / untrack() / getPresenceState() | Presence |
subscribe() / unsubscribe() | Connect and subscribe, or leave the channel |
send({event, payload}) | Broadcast to the channel |
events | Stream<RealtimeEvent> of the channel's events. From version 1.7.0, it carries only changes that match one of the channel's own handlers, plus server errors as RealtimeErrorEvent (code, message). |
status | ChannelStatus: idle, connecting, connected, disconnected or error |
RealtimeFilter(column:, op:, value:) supports the operators eq, neq, in, gt, lt and like. Changes are only emitted for tables with realtime enabled. See Realtime.
From version 1.7.0:
unsubscribe()removes the channel's server subscriptions (table:<schema>.<table>andbroadcast:<name>), unless another channel on the same socket still uses them.untrack()sends apresence.untrackmessage, which removes only your presence and keeps the broadcast subscription. Servers older than this message ignore it.- Reconnect backoff keeps growing while the server is down, instead of resetting to about 1 second.
Rules and limits:
- Register
onBroadcastbefore you callsend()ortrack(). A channel without a broadcast handler isn't subscribed to the broadcast stream. The server rejects sends from it, and it won't receive other users' presence. - One connection can hold 100 subscriptions. Broadcast payloads are limited to 64 KB and presence state to 8 KB.
Realtime as the signed-in user
From version 1.7.0, the client's realtime connection carries the signed-in user's access token, so each event is re-checked against RLS as that user. The token is read again on every connect, and the socket reconnects when a user signs in, signs out or changes. Call orbitnest.realtime.reconnect() to reconnect yourself. With nobody signed in, the connection uses only the anon key and receives every change on enabled tables.
In 1.6.3 the built-in connection used only the anon key. To get RLS-checked events there, create a separate OrbitNestRealtime with the user's token:
final userRealtime = OrbitNestRealtime(
baseUrl: orbitnest.baseUrl,
projectSlug: orbitnest.projectSlug,
apiKey: orbitnest.anonKey,
userJwt: orbitnest.auth.currentSession!.accessToken,
);Edge functions
final res = await orbitnest.functions.invoke('send-welcome', body: {'userId': userId});
print(res.status);
print(res.data);
final data = await orbitnest.function('send-welcome', params: {'userId': userId}); // returns data only| Method | Description |
|---|---|
invoke(name, {method = 'POST', body, headers, queryParameters}) | Returns FunctionResponse with data, status, statusText and headers |
call(name, {params, headers}) | POST alias |
get(name, {headers, queryParameters}) | GET request |
post(name, {body, headers}) | POST request |
put(name, {body, headers}) | PUT request |
delete(name, {body, headers}) | DELETE request |
Failures throw FunctionException. Results and errors are routed by function name, so a failing function doesn't fail other calls in flight. See Edge functions.
Analytics
orbitnest.analytics.identify(user.id, traits: {'plan': 'pro'});
orbitnest.analytics.screen('Checkout');
orbitnest.analytics.track('purchase', properties: {'amount': 49});
orbitnest.analytics.crash(error, stackTrace: stack);
await orbitnest.analytics.flush();Events are sent with platform: 'flutter' to POST /api/analytics/events. The queue flushes within 5 seconds of an event being queued, or immediately once 50 are waiting. anonymous_id and session_id are generated fresh for each client instance, which means each app launch. Failed batches are dropped silently, so analytics never throws. From version 1.7.0, crash() sends error_type, error_message and stack_trace, so crashes group by type, and dispose() flushes the queue before closing. See Analytics.
Jobs and migrations
orbitnest.jobs and orbitnest.migrations are deprecated from version 1.7.0 and will be removed. Neither works from an app:
- Jobs call Studio admin endpoints that need an admin access token, so the anon key and user sessions get
401. - Migrations (and
MigrationLogController) call routes the API doesn't provide, so the calls fail.
Manage jobs in Studio or with the MCP server, and run migrations with the CLI or the Node.js SDK.
Error handling
Failures throw subclasses of OrbitNestException, which has message, code and statusCode. From version 1.7.0 it also has body, the parsed error JSON. For example, a 402 with code QUOTA_EXCEEDED has quota, plan, limit and usage in body. See Plans & billing.
| Exception | Thrown by | Extra fields |
|---|---|---|
AuthException | auth | None |
DatabaseException | database | table, query, hint, details |
FunctionException | functions | None. From version 1.7.0, code is the API's error code. In 1.6.3 every function error had code UNKNOWN_FUNCTION_ERROR. |
try {
await orbitnest.auth.signInWithPassword(email: email, password: password);
} on AuthException catch (e) {
print('${e.code}: ${e.message}');
} on OrbitNestException catch (e) {
print('${e.statusCode} ${e.message}');
}Transport errors use codes such as CONNECTION_TIMEOUT, CONNECTION_ERROR, UNAUTHORIZED, FORBIDDEN, NOT_FOUND, RATE_LIMIT_EXCEEDED and INTERNAL_SERVER_ERROR. The full list is in the exported ErrorCodes class. Storage calls throw StorageException, which you can also catch as OrbitNestException.
Reactive state with BLoC
The SDK uses BLoC internally, and the async methods above cover most apps. For reactive UI you can listen to auth.onAuthStateChange, database.onStateChange and functions.onStateChange. From version 1.7.0, the database methods no longer go through DatabaseBloc, so database.onStateChange only reports events you dispatch to the bloc yourself. The package also exports AuthBloc, DatabaseBloc and FunctionsBloc with their events and states, for apps that wire their own BLoC setup.