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

pubspec.yaml
dependencies:
  orbitnest_studio_flutter: ^1.6.3
Terminal
flutter pub get

Configure

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:

.env
ORBITNEST_ANON_KEY=your_anon_key_here
ORBITNEST_DEBUG=false
pubspec.yaml
flutter:
  assets:
    - .env
VariableDefaultDescription
ORBITNEST_ANON_KEYRequired, unless you pass anonKey in codeYour project's anon key. The SDK decodes the project slug from its project_slug claim.
ORBITNEST_API_URLhttps://api.orbitnest.ioOverride 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_TIMEOUT180000Connect, send and receive timeout in milliseconds
ORBITNEST_DEBUGfalseSet 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

lib/main.dart
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.

MemberWhat it does
authSign-up, sign-in, OTP, SMS, MFA, passkeys and session state
databaseRow reads and writes, raw SQL
storagestorage.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
analyticsBatched event tracking
onSessionExpiredA Stream<void> that fires when the server rejects the refresh token
from(table)Query builder. See Query builder.
select, insert, update, delete, sqlShortcuts for the database methods that return the rows directly
signIn, signUp, signOut, currentUser(), currentSession()Auth shortcuts
projectSlug, baseUrl, anonKeyThe 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>, plus Authorization: Bearer <access token> while a user is signed in, or Bearer <anon key> when nobody is. RLS policies apply as the signed-in user, and edge functions receive them as ctx.user. From version 1.7.0, analytics requests carry apikey too, and a 401 from analytics never touches the session.
  • Sessions are stored with flutter_secure_storage and restored when the app starts. The SDK refreshes the access token when it has less than 5 minutes left. On a 401 it refreshes once and retries the request.
  • Session expiry. From version 1.7.0, onSessionExpired fires only when the server rejects the refresh token. These cases are returned to the caller as errors and keep the session: a 401 with nobody signed in, a 401 from an auth or analytics endpoint, a 401 or 403 on the retried request, and a refresh that fails because of the network, a timeout, 408, 429 or a 5xx. 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, 503 and 504 responses are retried up to 3 times, with exponential backoff from 500 ms up to 10 s. From version 1.7.0, a 429 that carries an error code (such as SMS_RATE_LIMITED) isn't retried, and uploads (FormData bodies) are never retried.

Access tokens last 15 minutes and refresh tokens 7 days, unless the project's JWT settings change those defaults.

Dart
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

Dart
// 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

Dart
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.

Dart
await orbitnest.auth.signInWithSms('+15555550123');      // sends a 6-digit code
await orbitnest.auth.verifySmsOtp(phone: '+15555550123', code: code); // creates the user on first sign-in

Each 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)

Dart
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
MethodDescription
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.

Dart
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

MethodDescription
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

MemberDescription
currentUserUser?. null when nobody is signed in. Stays set while other auth operations are in flight.
currentSessionSession? with accessToken, refreshToken, expiresIn and user
isAuthenticatedbool
onAuthStateChangeStream<AuthState>

AuthState is a sealed class with these subclasses:

StateFields
AuthInitialStateNone
AuthLoadingStateNone
AuthAuthenticatedStateuser, session
AuthUnauthenticatedStateNone
AuthMfaRequiredStatechallengeToken, factors
AuthOtpSentStateemail, message, type (signup or signin)
AuthPasswordResetSentStateemail, message
AuthUserUpdatedStateuser, message
AuthPasskeysListedStatedevices
AuthPasskeyRegisteredStatedevice
AuthPasskeyUpdatedStatedeviceId, message
AuthPasskeyRevokedStatedeviceId
AuthErrorStatemessage, code, details
Dart
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

Dart
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 API

select returns a PostgrestResponse with data (List<Map<String, dynamic>>), count and status.

  • filters are equality conditions combined with AND. A null value means IS NULL.
  • orderBy takes one entry, such as 'created_at.desc'. The API sorts by one column only.
  • limit and offset page through the rows. A page holds at most 100 rows.
  • columns is 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:

Dart
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();
SupportedThrows 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. inFilter values can't contain ,, and isFilter accepts only null, true or false.
  • update() and delete() take equality filters only (eq or match), 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

Dart
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 affected

From 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

Dart
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 timestamps

Inserts 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

Dart
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

Dart
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'),
);
MethodReturnsNotes
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})StringBuilt locally. Works only for public buckets.
createBucket()MapNeeds 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, list and download work only in public buckets, and upload and remove return 403.

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.

Dart
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

Dart
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
MemberDescription
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, onPresenceLeaveRoster callbacks
track(state) / untrack() / getPresenceState()Presence
subscribe() / unsubscribe()Connect and subscribe, or leave the channel
send({event, payload})Broadcast to the channel
eventsStream<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).
statusChannelStatus: 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> and broadcast:<name>), unless another channel on the same socket still uses them.
  • untrack() sends a presence.untrack message, 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 onBroadcast before you call send() or track(). 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:

Dart
final userRealtime = OrbitNestRealtime(
  baseUrl: orbitnest.baseUrl,
  projectSlug: orbitnest.projectSlug,
  apiKey: orbitnest.anonKey,
  userJwt: orbitnest.auth.currentSession!.accessToken,
);

Edge functions

Dart
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
MethodDescription
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

Dart
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.

ExceptionThrown byExtra fields
AuthExceptionauthNone
DatabaseExceptiondatabasetable, query, hint, details
FunctionExceptionfunctionsNone. From version 1.7.0, code is the API's error code. In 1.6.3 every function error had code UNKNOWN_FUNCTION_ERROR.
Dart
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.