Flutter and Dart
There is no official Dart client. The API is plain HTTP, so package:http plus package:web_socket_channel for realtime is the whole story. What follows is a complete client you can paste into a project and extend.
Dependencies
dependencies:
http: ^1.2.0
web_socket_channel: ^3.0.0
shared_preferences: ^2.3.0
The client
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
class BaselyraException implements Exception {
BaselyraException(this.code, this.message, this.status);
final String code;
final String message;
final int status;
@override
String toString() => 'Baselyra $status $code: $message';
}
class Baselyra {
Baselyra({required this.url, required this.anonKey});
final String url;
final String anonKey;
String? _accessToken;
String? _refreshToken;
Map<String, String> get _headers => {
'apikey': anonKey,
'authorization': 'Bearer ${_accessToken ?? anonKey}',
'content-type': 'application/json',
};
dynamic _decode(http.Response res) {
final body = res.body.isEmpty ? null : jsonDecode(res.body);
if (res.statusCode >= 400) {
final error = (body is Map && body['error'] is Map) ? body['error'] as Map : const {};
throw BaselyraException(
(error['code'] ?? 'http_error').toString(),
(error['message'] ?? res.reasonPhrase ?? 'Request failed').toString(),
res.statusCode,
);
}
return body;
}
// ---- auth ----
Future<void> signIn(String email, String password) async {
final res = await http.post(
Uri.parse('$url/auth/v1/token?grant_type=password'),
headers: _headers,
body: jsonEncode({'email': email, 'password': password}),
);
await _store(_decode(res) as Map<String, dynamic>);
}
Future<void> signUp(String email, String password, {Map<String, dynamic>? data}) async {
final res = await http.post(
Uri.parse('$url/auth/v1/signup'),
headers: _headers,
body: jsonEncode({'email': email, 'password': password, if (data != null) 'data': data}),
);
final body = _decode(res) as Map<String, dynamic>;
// Null when the instance confirms email addresses — the user must click the link.
if (body['session'] != null) await _store(body['session'] as Map<String, dynamic>);
}
Future<void> signOut() async {
await http.post(Uri.parse('$url/auth/v1/logout'), headers: _headers);
_accessToken = null;
_refreshToken = null;
final prefs = await SharedPreferences.getInstance();
await prefs.remove('bl_refresh_token');
}
/// Access tokens last an hour; call this at startup to trade the stored
/// refresh token for a fresh pair. A refresh token is single-use, so the new
/// one MUST be stored or the next launch signs the user out.
Future<bool> restore() async {
final prefs = await SharedPreferences.getInstance();
final stored = prefs.getString('bl_refresh_token');
if (stored == null) return false;
final res = await http.post(
Uri.parse('$url/auth/v1/token?grant_type=refresh_token'),
headers: {'apikey': anonKey, 'content-type': 'application/json'},
body: jsonEncode({'refresh_token': stored}),
);
if (res.statusCode >= 400) {
await prefs.remove('bl_refresh_token');
return false;
}
await _store(jsonDecode(res.body) as Map<String, dynamic>);
return true;
}
Future<void> _store(Map<String, dynamic> session) async {
_accessToken = session['access_token'] as String?;
_refreshToken = session['refresh_token'] as String?;
final prefs = await SharedPreferences.getInstance();
if (_refreshToken != null) await prefs.setString('bl_refresh_token', _refreshToken!);
}
// ---- data ----
Future<List<dynamic>> select(String table, {Map<String, String> query = const {}}) async {
final uri = Uri.parse('$url/rest/v1/$table').replace(queryParameters: query);
return _decode(await http.get(uri, headers: _headers)) as List<dynamic>;
}
Future<List<dynamic>> insert(String table, Map<String, dynamic> row) async {
final res = await http.post(
Uri.parse('$url/rest/v1/$table'),
headers: {..._headers, 'Prefer': 'return=representation'},
body: jsonEncode(row),
);
return _decode(res) as List<dynamic>;
}
Future<List<dynamic>> update(
String table, Map<String, String> filters, Map<String, dynamic> patch) async {
if (filters.isEmpty) throw ArgumentError('a filter is required: an unfiltered PATCH rewrites the table');
final uri = Uri.parse('$url/rest/v1/$table').replace(queryParameters: filters);
final res = await http.patch(
uri,
headers: {..._headers, 'Prefer': 'return=representation'},
body: jsonEncode(patch),
);
return _decode(res) as List<dynamic>;
}
/// The server refuses an unfiltered delete, so `filters` must not be empty.
Future<void> delete(String table, Map<String, String> filters) async {
if (filters.isEmpty) throw ArgumentError('a filter is required: an unfiltered DELETE empties the table');
final uri = Uri.parse('$url/rest/v1/$table').replace(queryParameters: filters);
_decode(await http.delete(uri, headers: _headers));
}
Future<dynamic> rpc(String fn, Map<String, dynamic> args) async {
final res = await http.post(
Uri.parse('$url/rest/v1/rpc/$fn'),
headers: _headers,
body: jsonEncode(args),
);
return _decode(res);
}
// ---- storage ----
Future<Map<String, dynamic>> upload(
String bucket, String key, List<int> bytes, String mime) async {
final request = http.MultipartRequest('POST', Uri.parse('$url/storage/v1/object/$bucket/$key'))
..headers['authorization'] = 'Bearer ${_accessToken ?? anonKey}'
..files.add(http.MultipartFile.fromBytes('file', bytes, filename: key.split('/').last));
final res = await http.Response.fromStream(await request.send());
return _decode(res) as Map<String, dynamic>;
}
String publicUrl(String bucket, String key) => '$url/storage/v1/object/public/$bucket/$key';
Future<String> signedUrl(String bucket, String key, {int expiresIn = 3600}) async {
final res = await http.post(
Uri.parse('$url/storage/v1/object/sign/$bucket/$key'),
headers: _headers,
body: jsonEncode({'expiresIn': expiresIn}),
);
return (_decode(res) as Map<String, dynamic>)['url'] as String;
}
// ---- realtime ----
/// One socket, one channel. Cancel the returned subscription on dispose.
Stream<Map<String, dynamic>> subscribe(String channel, {String? filter}) {
final wsUrl = url.replaceFirst(RegExp(r'^http'), 'ws');
final socket = WebSocketChannel.connect(
Uri.parse('$wsUrl/realtime/v1?apikey=${_accessToken ?? anonKey}'),
);
socket.sink.add(jsonEncode({
'type': 'subscribe',
'channel': channel,
if (filter != null) 'filter': filter,
}));
return socket.stream
.map((event) => jsonDecode(event as String) as Map<String, dynamic>)
.where((frame) => frame['type'] == 'postgres_changes' || frame['type'] == 'broadcast');
}
}
Using it
final bl = Baselyra(url: 'https://api.example.com', anonKey: '<anon key>');
await bl.restore(); // at startup
await bl.signIn('ada@example.com', 'correct-horse-battery');
final notes = await bl.select('notes', query: {
'select': 'id,title',
'order': 'created_at.desc',
'limit': '20',
});
await bl.insert('notes', {'title': 'From Flutter'});
final sub = bl.subscribe('public:notes', filter: 'archived=is.false').listen((frame) {
debugPrint('${frame['event']} ${frame['new']}');
});
// later: await sub.cancel();
Things worth knowing on mobile
- Store the rotated refresh token. Every refresh returns a new one and retires the old. Replaying a spent token revokes the whole chain and the user is signed out — which is exactly what an app that forgets to save the new value does on its second launch.
- Refresh at startup, not on a 401. A stored token is usually stale by the next launch, and a failure-driven refresh means every call site has to know how to replay itself.
- The OS suspends sockets in the background. Re-subscribe and refetch on resume rather than trusting the change feed to have caught everything while the app was asleep.
- Filters are query parameters, so
Uri.replace(queryParameters:)does the encoding for you. A literal+in a value must be encoded or it decodes to a space. - Consider
flutter_secure_storageinstead ofSharedPreferencesfor the refresh token — it is a 30-day credential.
Failure modes
| What you see | Why | Fix |
|---|---|---|
| Signed out on the second launch | The rotated refresh token was not stored | Store the new one on every refresh |
400 invalid_credentials for a real account | Same message for a wrong password and an unknown address | Show it verbatim |
signUp returns no session | AUTH_CONFIRM_EMAIL is on | The user has to click the link first |
| An empty list where the Studio shows rows | RLS admits nothing for this role | Write a policy |
| The realtime stream never emits | The table has no trigger, or a proxy ate the Upgrade header | select baselyra.enable_realtime('public.notes'), then the 101 test |
ArgumentError: a filter is required | The guard above fired | Pass a filter — the server would have refused it anyway |