Flutter SDK for Koolbase — Backend as a Service built for mobile developers.
Auth, database, storage, realtime, functions, feature flags, remote config, version enforcement, code push, server-driven UI, logic engine, analytics, and cloud messaging — one SDK, one initialize() call.
-
Create a free account at app.koolbase.com
-
Create a project and copy your public key from Environments
-
Add the SDK
dependencies:
koolbase_flutter: ^10.1.2- Initialize before
runApp():
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Koolbase.initialize(KoolbaseConfig(
publicKey: 'pk_live_xxxx',
baseUrl: 'https://api.koolbase.com',
));
runApp(MyApp());
}That's it. Every feature below is now available via Koolbase.*.
Email + password, Apple Sign-In, Google Sign-In, and phone + OTP — out of the box.
// Register
await Koolbase.auth.register(email: 'user@example.com', password: 'password');
// Login
await Koolbase.auth.login(email: 'user@example.com', password: 'password');
// Current user
final user = Koolbase.auth.currentUser;
// Logout
await Koolbase.auth.logout();
// Password reset
await Koolbase.auth.forgotPassword(email: 'user@example.com');
// Resend the email-verification link (authenticated, unverified user)
final result = await Koolbase.auth.resendVerificationEmail();
if (result.alreadyVerified) {
// nothing sent — the account is already verified
} else {
// email re-sent; result.cooldownUntil powers a "resend in N seconds" countdown
}
// Throws ResendCooldownException (retry after cooldown) or
// ResendDailyCapException if throttled.
// Listen to auth state changes
final subscription = Koolbase.auth.authStateChanges.listen((user) {
print(user != null ? 'signed in' : 'signed out');
});Apple Sign-In uses the native authentication flow via the sign_in_with_apple package:
import 'package:sign_in_with_apple/sign_in_with_apple.dart';
final credential = await SignInWithApple.getAppleIDCredential(
scopes: [
AppleIDAuthorizationScopes.email,
AppleIDAuthorizationScopes.fullName,
],
);
final user = await Koolbase.auth.signInWithApple(
identityToken: credential.identityToken!,
nonce: credential.nonce,
fullName: credential.givenName != null
? AppleFullName(
givenName: credential.givenName,
familyName: credential.familyName,
)
: null,
);Configure Apple Sign-In for your environment with your iOS app's Bundle ID. Full setup guide at docs.koolbase.com/auth/oauth.
Google Sign-In uses the native authentication flow via the google_sign_in package:
import 'package:google_sign_in/google_sign_in.dart';
final googleUser = await GoogleSignIn().signIn();
final googleAuth = await googleUser?.authentication;
final user = await Koolbase.auth.signInWithGoogle(
idToken: googleAuth!.idToken!,
);Configure Google Sign-In for your environment with the OAuth client IDs from Google Cloud Console (typically one each for iOS, Android, and web). Full setup guide at docs.koolbase.com/auth/oauth.
// Send a one-time code
await Koolbase.auth.sendOtp(phoneE164: '+233200000000');
// Verify and sign in
await Koolbase.auth.verifyOtp(
phoneE164: '+233200000000',
code: '123456',
);
// Or link a phone to an existing account
await Koolbase.auth.linkPhone(
phoneE164: '+233200000000',
code: '123456',
);Configure your SMS provider (Twilio, Africa's Talking, or Hubtel) in the dashboard under Phone Auth.
Auth is automatic (v5+). Database, storage, and functions calls authenticate as the currently signed-in user — nothing to pass, no manual wiring. Log in (or restore a session) and every data-plane request carries that identity.
owner/authenticatedcollections require an active session.
// Insert
await Koolbase.db.collection('posts').insert({
'title': 'Hello world',
'body': 'My first post',
});
// Query
final records = await Koolbase.db.collection('posts').get();
// Read fields off a record
final posts = await Koolbase.db.collection('posts').get();
for (final post in posts.records) {
print(post['title']); // field access (shorthand for post.data['title'])
print(post.id); // record id
}
// Filter
final filtered = await Koolbase.db
.collection('posts')
.where('status', 'published')
.get();
// Relational data
final result = await Koolbase.db
.collection('posts')
.populate(['author_id:users'])
.get();
// Update
await Koolbase.db.collection('posts').doc('record-id').update({'title': 'Updated'});
// Delete
await Koolbase.db.collection('posts').doc('record-id').delete();Insert, update, and delete all fall back to a local queue when the network is unreachable. The change is applied to the local cache immediately and returned optimistically, so the UI moves on, and sent when connectivity returns.
// Offline: queued, cached locally, returns optimistically
final record = await Koolbase.db.insert(
collection: 'weight_readings',
data: {'kg': 68.4},
);
await Koolbase.db.doc(record.id).update({'kg': 68.6});
await Koolbase.db.doc(record.id).delete();
// Sync happens automatically on reconnect. To force it:
await Koolbase.db.syncPendingWrites();A server-side rejection is never queued. A unique-constraint violation, a validation failure, or a permission denial surfaces immediately — only a genuine network failure defers.
An update or delete can only be queued if the SDK knows what the record looked like when the change was made. Replaying a change without that means applying it blindly: whatever else happened to the record in the meantime is overwritten, silently, with nobody able to tell.
The SDK has that state if the record has been read on this device — through a
query, a doc().get(), or because it was created here and is still queued. If it
has not, the write is refused rather than queued:
try {
await Koolbase.db.doc(id).update({'kg': 68.6});
} on KoolbaseOfflineBaselineUnavailableException {
// Never read on this device. Read it, or make the change while online.
}That is deliberate rather than lenient. Queueing it anyway would mean most offline updates are conflict-safe and some quietly are not, which is a worse guarantee than a clear refusal.
When a queued write replays, the server applies it only if the record still carries the revision the change was based on. If something changed it meanwhile — another device, another user, a Function — the write is refused and becomes a conflict: held, not applied, and not lost.
Conflicts survive restarts. Closing the app does not change the outcome of a write.
final conflicts = await Koolbase.db.conflicts();
for (final c in conflicts) {
c.local; // the change the user made
c.server; // the record as the server holds it now
c.divergentFields; // where they disagree
await c.resolveWithLocal(); // reapply the user's change
await c.resolveWithServer(); // keep the server's version
await c.resolveWithMerge({...}); // apply something composed from both
await c.abandon(); // drop it, neither side wins
}
// Or watch, to show a badge or a review screen:
Koolbase.db.watchConflicts().listen(...);A rejected insert — a duplicate under a unique constraint — is held the
same way, as an insert-conflict. Resolving it is the insert, retried:
resolveWithMerge carries the amended data (fix the colliding value and go);
resolveWithServer means the colliding row stands, clearing without a request.
Resolving is itself conditional: if the record has moved again while someone was deciding, the resolution is refused rather than overwriting a change nobody has seen — and the refusal updates the conflict in place to the server's current state (revision and record), so the retry is conditional against reality. Review what changed and decide again; the second attempt succeeds unless the record moves yet again.
Conflicts do not expire. An app that never reads
conflicts()accumulates them in local storage indefinitely, invisible to the user, with the changes they hold never applied. If you support offline editing, surface them somewhere. Automatic expiry would hide the problem while reintroducing exactly the silent loss this design prevents.
The queue lives on the device and outlives sessions, so a write made offline can still be pending after someone else has signed in. Those writes are held, not sent — replaying them under a different token would file one person's records under another's name. Sign back in as the same user and they sync normally.
Upgrading: writes queued before 9.7.0 have no recorded owner and are never replayed. Records cached before 9.8.0 have no revision, so they cannot be edited offline until they are read again. Neither is lost; both are logged when skipped.
The counterpart to conflicts(), one step earlier in the lifecycle: a conflict
is a write the server refused; a pending write is one the server has not seen
yet. Both are durable state worth surfacing — a queue nobody can see
accumulates invisibly, and the changes it holds feel saved to the user while
existing only on this device.
// Snapshot — "3 changes waiting"
final pending = await Koolbase.db.pendingWrites();
// Live, for a sync badge
Koolbase.db.watchPendingWrites().listen((writes) {
syncBadge.count = writes.length;
});The moment this API exists for is logout. The queue outlives the session (see above), so a user signing out with pending writes walks away believing their edits saved — and they sync whenever that user next signs in on this device, which may be never. Warn first:
final pending = await Koolbase.db.pendingWrites();
if (pending.isNotEmpty) {
// "You have ${pending.length} unsynced changes. Sync now, or they wait
// until you next sign in on this device."
await Koolbase.db.syncPendingWrites();
}Each PendingWrite carries what an app needs to display — operation,
collection, record id, the changed data, when it was queued, and how many send
attempts have failed. Replay internals (baselines, revisions) are deliberately
not exposed, and a delete carries no data: there is nothing the user
"changed", only a removal. Both methods are per-user — another account's queue
on this device is not visible.
A write that would violate a unique constraint throws KoolbaseConflictException:
try {
await Koolbase.db.collection('users').insert({'email': email});
} on KoolbaseConflictException catch (e) {
showError('That ${e.field ?? 'value'} is already registered.');
}See Error handling for the full set of typed exceptions.
Insert a record, or update the existing one matching a filter. The server decides: one match updates it, no match inserts (seeded with the match fields), more than one match errors.
final result = await Koolbase.db.upsert(
collection: 'profiles',
match: {'user_id': userId},
data: {'weight_kg': 70},
);
print(result.created); // true if inserted, false if updated
print(result.record.id);Online-only: an upsert needs the server's view to decide insert vs update, so unlike
insertit isn't queued offline and throws on network failure.
Bulk-delete every record matching a filter. Returns the number deleted.
final deleted = await Koolbase.db.deleteWhere(
collection: 'sessions',
filters: {'user_id': userId, 'status': 'expired'},
);A non-empty filter is required — Koolbase won't delete an entire collection. The collection's delete rule applies; for
owner/scopedrules the delete is scoped to your own records. Online-only.
Find records by meaning, not just field equality. Koolbase ships three
retrieval modes from a single API — pick the one that matches your
query characteristics, or use hybrid as a strong production default.
Declare a vector field on the collection from the dashboard or CLI first (picking a dimension; v1 supports 384, 768, 1024, and 1536).
// Semantic (default) — pure vector search via HNSW + cosine. Best for
// fuzzy or conceptual queries where exact words don't have to match.
final result = await Koolbase.db.collection('articles').searchSemantic(
field: 'content_embedding',
queryText: 'how do I move quicker?',
limit: 10,
);
// Lexical — pure BM25 over the field's source text (Postgres
// ts_rank_cd). Best for exact terms, product codes, names, acronyms.
final result = await Koolbase.db.collection('articles').searchSemantic(
field: 'content_embedding',
queryText: 'CVE-2024-1234',
mode: KoolbaseSearchMode.lexical,
limit: 10,
);
// Hybrid — vector + lexical fused with reciprocal rank fusion (k=60).
// Generally the strongest default; both rankers vote and the fusion
// score promotes records that score well on either signal.
final result = await Koolbase.db.collection('articles').searchSemantic(
field: 'content_embedding',
queryText: 'production deploy pipeline',
mode: KoolbaseSearchMode.hybrid,
limit: 10,
);For semantic and hybrid modes, pass minSimilarity (0..100) to drop
results below a similarity threshold server-side — saves bandwidth on
weak matches:
final result = await Koolbase.db.collection('articles').searchSemantic(
field: 'content_embedding',
queryText: 'how do I move quicker?',
mode: KoolbaseSearchMode.hybrid,
minSimilarity: 70, // only matches at least 70% similar
limit: 10,
);minSimilarity is rejected by the server when used with
KoolbaseSearchMode.lexical — BM25 rank scores aren't comparable to
cosine similarity, and silently ignoring the parameter would produce
confusing "I set 80, why did weak results return?" behavior.
Configure an AI provider on the project once (Gemini's free tier works; OpenAI also supported), tag the vector field with the provider/model/source_field, and Koolbase auto-embeds records as they're inserted or updated. Lexical indexing happens automatically on the same write, so all three search modes work without extra setup:
// One-time setup: configure provider + tag the vector field via the
// dashboard. Then just write records normally — vectors AND lexical
// rows land within ~1s.
await Koolbase.db.collection('articles').create({
'title': 'How to ship faster',
'content': 'Cut scope ruthlessly. Ship the smallest useful slice...',
});
// Iterate over hits the same way regardless of mode:
for (final hit in result.hits) {
print('${hit.record['title']} (${hit.distance.toStringAsFixed(3)})');
}
// Backfill records that pre-date the auto-embed config:
await Koolbase.db.collection('articles').embedText(
recordId: article.id,
vectorField: 'content_embedding',
);
// Or override the source — useful for combining fields:
await Koolbase.db.collection('articles').embedText(
recordId: article.id,
vectorField: 'content_embedding',
text: '${article.title}\n\n${article.summary}',
);If you'd rather control the embedding model yourself, pass a vector instead of text. Note that lexical and hybrid modes require text, since BM25 has no notion of "vector queries":
// Set a vector you've encoded yourself
await Koolbase.db.doc(articleId).setVector(
'embedding',
await myEmbeddingModel.encode(article.content),
);
// Read it back
final v = await Koolbase.db.doc(articleId).getVector('embedding');
print('${v.vector.length}-dim, updated ${v.updatedAt}');
// Search with a precomputed query vector — semantic mode only.
final result = await Koolbase.db.collection('articles').searchSemantic(
field: 'embedding',
queryVector: await myEmbeddingModel.encode(userQuery),
limit: 10,
where: {'category': 'tech'},
);
// Remove a record's vector when no longer needed
await Koolbase.db.doc(articleId).deleteVector('embedding');- Pass exactly one of
queryVectororqueryText. Supplying both or neither throwsArgumentError. queryVectoris for semantic mode only. Lexical and hybrid modes need raw text — the server uses it for BM25 ranking (and embeds it inline for the vector half of hybrid).- Vector length must match the declared dimension. Mismatches throw
KoolbaseVectorDimensionMismatchExceptionwith expected and actual dimensions in the message. minSimilaritymust be 0..100. Values outside that range throwArgumentErrorclient-side before the request is sent.- Online-only. Vector operations are not cached locally or queued offline — HNSW similarity and BM25 ranking have no useful offline semantics.
- Read rule applies post-search. Semantic search respects the
collection's read rule the same way
.get()does:owner/scoped/conditionalrecords are filtered to the caller after retrieval, so strict rules may return fewer thanlimitresults. embedTextis async. Returns when the job is queued (~100ms). The vector typically lands within 1 second once the worker picks it up.- Higher dimensions coming. OpenAI's
text-embedding-3-largeships at 3072 dimensions, supported in a future release once pgvector is upgraded. In the meantime, use your model'sdimensions=1536parameter (Matryoshka truncation) for full compatibility.
See Semantic search docs for setup, provider configuration, embedding model recommendations, and when to pick each mode.
Upload and serve files via presigned URLs to Cloudflare R2. Uploads are
safe-by-default (v6+) — uploading to a path that's already taken throws
KoolbaseStorageConflictException instead of silently replacing the
existing file. Pass overwrite: true for true upsert semantics.
// Upload — rejects if `user-123.jpg` already exists
await Koolbase.storage.upload(
bucket: 'avatars',
path: 'user-123.jpg',
file: file,
);
// Upload — silently replaces any existing object at this path
await Koolbase.storage.upload(
bucket: 'avatars',
path: 'user-123.jpg',
file: file,
overwrite: true,
);
// Get download URL
final url = await Koolbase.storage.getDownloadUrl(
bucket: 'avatars',
path: 'user-123.jpg',
);
// Delete
await Koolbase.storage.delete(bucket: 'avatars', path: 'user-123.jpg');For files in public buckets, you can construct the stable CDN URL directly — no network call, no expiry, embeddable anywhere a browser fetches a URL.
// From a KoolbaseObject you already have (e.g. from upload() or another read)
final obj = result.object;
final url = obj.publicUrl('avatars');
// url is null for private-bucket objects; the CDN URL for public-bucket ones.
if (url != null) {
// Safe to use — file lives in the public R2 bucket
return Image.network(url);
}
// For build-time URL construction (no Object on hand)
final url = KoolbaseStorageClient.publicUrl(
projectId: 'proj_abc',
bucket: 'avatars',
path: 'user-123.jpg',
);
// Always returns the URL pattern; caller is responsible for knowing
// the file lives in a public bucket. For files in private buckets,
// the resulting URL will 404.URLs follow the pattern https://cdn.koolbase.com/{project_id}/{bucket}/{path} — long-lived, edge-cached, no authentication. For files in private buckets, use getDownloadUrl instead, which returns a 1-hour presigned URL.
Public bucket URLs can be transformed at the edge — resize, reformat, optimize — without any preprocessing. Two ways:
Direct transforms — pass a KoolbaseImageTransform to publicUrl:
final url = KoolbaseStorageClient.publicUrl(
projectId: 'proj_abc',
bucket: 'avatars',
path: 'user-123.jpg',
transform: const KoolbaseImageTransform(
width: 200,
height: 200,
fit: KoolbaseImageFit.cover,
format: KoolbaseImageFormat.auto,
quality: 85,
),
);Named presets — store an option set server-side (via the dashboard or REST API), reference it by name:
final url = KoolbaseStorageClient.publicUrlWithPreset(
projectId: 'proj_abc',
presetName: 'thumbnail',
bucket: 'avatars',
path: 'user-123.jpg',
);
// Or from a KoolbaseObject instance:
final url = obj.publicUrlWithPreset('avatars', 'thumbnail');Available options: width and height (1–2000), format
(auto/webp/avif/jpeg/png), quality (1–100), fit
(scaleDown/contain/cover/crop/pad), dpr (1–3), gravity
(auto/center/top/bottom/left/right/topLeft/topRight/
bottomLeft/bottomRight). Transformed responses are edge-cached for 4
hours; Cloudflare includes 5,000 unique transformations/month free per
account.
See Image Transforms docs for the full reference.
For user-supplied filenames, prompt the user before overwriting:
try {
await Koolbase.storage.upload(
bucket: 'documents',
path: filename,
file: file,
);
} on KoolbaseStorageConflictException catch (e) {
final ok = await confirmDialog('${e.path} already exists. Overwrite?');
if (ok) {
await Koolbase.storage.upload(
bucket: 'documents',
path: filename,
file: file,
overwrite: true,
);
}
}Buckets can be configured at creation time with a total size cap
(max_size_bytes), a per-file cap (max_file_size_bytes), and a
content-type allowlist (allowed_mime_types, supports image/*-style
wildcards). The server surfaces violations as typed exceptions:
try {
await Koolbase.storage.upload(
bucket: 'user-photos',
path: filename,
file: file,
);
} on KoolbaseStorageMimeTypeException {
showError('That file type is not allowed in this bucket.');
} on KoolbaseStorageFileTooLargeException {
showError('That file is too big — pick a smaller one.');
} on KoolbaseStorageQuotaExceededException {
showError('This bucket is full — delete some files and try again.');
}MIME enforcement runs at presign time — no bytes are transferred before rejection. File-size and quota enforcement run at confirm time; the server cleans up the underlying R2 object before returning the error, so nothing leaks.
See Error handling for the full set of storage exceptions.
For buckets with versioning enabled, every overwrite preserves the prior content as a history version, and deletes are soft (recoverable until force-purged). Enable versioning on a bucket from the dashboard.
// List all versions of a path, newest first
final versions = await Koolbase.storage.listVersions(
bucket: 'documents',
path: 'contract.pdf',
);
for (final v in versions) {
print('${v.versionId}: size=${v.size} isCurrent=${v.isCurrent}');
}
// Download a specific historical version
final url = await Koolbase.storage.getDownloadUrl(
bucket: 'documents',
path: 'contract.pdf',
versionId: '019e98ed-eed6-7e71-...',
);
// Bring a history version back as current
// (the existing current is snapshotted to history first)
final restored = await Koolbase.storage.restoreVersion(
bucket: 'documents',
path: 'contract.pdf',
versionId: '019e98ed-eed6-7e71-...',
);
// Hard-remove a single history version (row + R2 bytes)
await Koolbase.storage.purgeVersion(
bucket: 'documents',
path: 'contract.pdf',
versionId: 'old-version-id',
);
// Wipe the entire timeline for a path - every version, every R2 key
await Koolbase.storage.delete(
bucket: 'documents',
path: 'contract.pdf',
forcePurge: true,
);A few behaviors worth knowing:
- Overwrite snapshots automatically. Upload to a path that already exists in a versioned bucket and the prior bytes are preserved as history; the upload becomes the new current.
- Delete is soft by default. On a versioned bucket,
deletesnapshots the current content and records a delete marker. The content is still recoverable viarestoreVersionuntil force-purged. - Restore is itself a versioned event. The previously-current row
gets snapshotted before the target's bytes overwrite canonical. The
restored row gets a fresh
versionId; the target stays in history at its original id - so you can always undo a restore. - Delete markers can be listed but not downloaded. A marker has
size == 0,isDeleteMarker == true, and no bytes. CallinggetDownloadUrlwith a marker'sversionIdthrows.
Stream live changes on a collection. Uses the signed-in user's session, so
subscribe after login. Supports collections whose read rule is public or
authenticated.
final sub = Koolbase.realtime.on(collection: 'messages').listen((event) {
// event.type -> recordCreated | recordUpdated | recordDeleted
if (event.type == RealtimeEventType.recordDeleted) {
print('deleted ${event.recordId}');
} else {
print('${event.type}: ${event.record}');
}
});
// Or filter to one kind:
Koolbase.realtime.onRecordCreated(collection: 'messages').listen(print);
await sub.cancel();The socket opens lazily, is shared across subscriptions, and reconnects automatically. The project is taken from your session — you don't pass it.
Invoke deployed serverless functions. When a user is signed in via Koolbase.auth, their access token is automatically forwarded — the function receives the caller's identity via ctx.auth. No token handling on the client side.
// Invoke a deployed function
final result = await Koolbase.functions.invoke(
'send-welcome-email',
body: {'userId': '123'},
);
if (result.success) print(result.data);Inside the function, read the caller:
// In your deployed Dart function
Future<Map<String, dynamic>> handler(Map<String, dynamic> ctx) async {
final userId = (ctx['auth'] as Map?)?['user_id'] as String?;
if (userId == null) {
return {'error': {'code': 'AUTH_REQUIRED'}, 'status': 401};
}
// Authenticated logic here
return {'ok': true};
}Token refresh is transparent — the SDK reads the current token fresh on every invoke. Full docs at docs.koolbase.com/functions/authentication.
// Feature flag
if (Koolbase.isEnabled('new_checkout')) {
// show new checkout
}
// Remote config
final timeout = Koolbase.configInt('api_timeout_ms', fallback: 3000);
final url = Koolbase.configString('api_url', fallback: 'https://api.example.com');
final dark = Koolbase.configBool('force_dark_mode', fallback: false);final result = Koolbase.checkVersion();
switch (result.status) {
case VersionStatus.forceUpdate:
// Block the app — show update screen
break;
case VersionStatus.softUpdate:
// Show a banner
break;
case VersionStatus.upToDate:
break;
}Push config overrides, feature flag overrides, and UI updates to your app without a store release.
await Koolbase.initialize(KoolbaseConfig(
publicKey: 'pk_live_xxxx',
baseUrl: 'https://api.koolbase.com',
codePushChannel: 'stable',
));
// Bundle values transparently override Remote Config + Feature Flags
final timeout = Koolbase.configInt('api_timeout_ms', fallback: 3000);
final enabled = Koolbase.isEnabled('new_checkout_flow');
// Directive handlers
Koolbase.codePush.onDirective('force_logout_all', (value) {
if (value == true) Koolbase.auth.logout();
});Recall a bundle at any time (koolbase bundle recall) to pull it from a channel. Devices on the recalled bundle revert to the previous published bundle — or to the app's built-in defaults if there is none — on their next cold launch.
Ship actual Dart code changes over the air — no store release — on Android and iOS builds compiled with the Koolbase engine via the Koolbase CLI (koolbase release android, koolbase build ios). The SDK checks in, downloads, and stages a signed patch; on the next cold launch the patch is verified (Ed25519 signature + build_id pinned to the exact binary) and applied before the app's first frame, so users simply relaunch into the updated code — no restart prompt, no flash of the old version. Automatic safeguards: a patch that fails verification is quarantined and the app boots clean; a patched boot that crashes is rolled back on the following launch (crash-revert); and a patch invalidated by an app-store update is discarded automatically.
The SDK also reports the build's Flutter engine version on check-in, so the resolver only serves a patch built for the same engine. This is automatic — koolbase build / koolbase release stamp the version into the build and the SDK sends it; there is nothing to configure. It prevents a patch built on one Flutter version from reaching a device running another (whose deterministic build_id or store version could otherwise collide). Apps built before this was added simply omit the version and fall back to the prior matching behavior.
import 'package:koolbase_flutter/koolbase_flutter.dart';
final patcher = KoolbaseVmPatchClient(
baseUrl: 'https://api.koolbase.com',
apiKey: 'pk_live_xxxx',
channel: 'stable',
);
// Check in, download, and stage any available patch. Applies on next launch.
await patcher.init();VM-level push requires an app built with the Koolbase engine. A standard Flutter build can still use bundle push above, but not Dart code push. On a standard build the VM-patch surface degrades gracefully — patch application reports "engine not present" and the app runs normally; the package compiles on stock Flutter with no configuration. (9.4.0 broke stock compilation entirely; fixed in 9.4.1.)
Mark a bundle mandatory in the dashboard (or via PATCH /mandatory) when every device must apply it before continuing. The SDK surfaces it two ways — a push callback and a pollable flag:
await Koolbase.initialize(KoolbaseConfig(
publicKey: 'pk_live_xxxx',
baseUrl: 'https://api.koolbase.com',
// Fires the moment a mandatory bundle is staged for the next launch
onMandatoryUpdate: (info) {
showRestartRequiredDialog(version: info.version);
},
));
// Or poll it — e.g. when the app resumes — before letting the user proceed
if (Koolbase.codePush.hasMandatoryUpdate) {
showRestartRequiredDialog();
}A mandatory bundle still activates on the next cold launch like any other; the callback and flag just let you prompt the user to restart now instead of waiting.
Need to ship raw files and read them yourself? Use Storage instead.
Push new screen layouts OTA using Flutter's official rfw package. Change your app UI without shipping a new binary.
// Wrap your app
KoolbaseCodePushScope(
client: Koolbase.codePush,
child: MaterialApp(...),
)
// Drop a dynamic screen anywhere
KoolbaseDynamicScreen(
screenId: 'onboarding',
data: { 'username': user.name },
onEvent: (name, args) {
if (name == 'get_started') Navigator.pushNamed(context, '/home');
},
fallback: const OnboardingScreen(),
)Define conditional app behavior as data in your Runtime Bundle — no code changes required.
final result = Koolbase.executeFlow(
flowId: 'on_checkout_tap',
context: { 'plan': user.plan, 'usage': user.usage },
);
if (result.hasEvent) {
switch (result.eventName) {
case 'show_upgrade': Navigator.pushNamed(context, '/upgrade');
case 'go_checkout': Navigator.pushNamed(context, '/checkout');
}
}v2 operators: eq, neq, gt, gte, lt, lte, contains, starts_with, ends_with, in_list, not_in_list, between, is_true, is_false, exists, not_exists, and, or
Full docs at docs.koolbase.com/sdk/logic-engine.
Track screen views, custom events, and user behaviour. View DAU, WAU, MAU, funnels, and retention in the Koolbase dashboard.
// Add to MaterialApp for automatic screen tracking
MaterialApp(
navigatorObservers: [
KoolbaseNavigatorObserver(client: Koolbase.analytics),
],
)
// Custom events
Koolbase.analytics.track('purchase', properties: {
'value': 1200,
'currency': 'GHS',
});
// User identity
Koolbase.analytics.identify(user.id);
Koolbase.analytics.setUserProperty('plan', 'pro');
// On logout
Koolbase.analytics.reset();// Register FCM token
final fcmToken = await FirebaseMessaging.instance.getToken();
await Koolbase.messaging.registerToken(
token: fcmToken!,
platform: 'android', // or 'ios'
);Sending is server-initiated — from your backend or a Koolbase Function with a
secret kb_live_ key, never the app (the publishable key ships in your binary
and can't send). See the Cloud Messaging docs.
curl -X POST https://api.koolbase.com/v1/messaging/send \
-H "Authorization: Bearer kb_live_..." \
-H "Content-Type: application/json" \
--data '{"project_id":"...","token":"...","title":"Your order is ready","body":"Pick up at counter 3"}'Koolbase throws typed exceptions you can catch to branch on what went wrong.
The SDK selects the exception from the server's stable error code, so your
handling doesn't depend on message text.
All data-layer failures extend KoolbaseDataException (which implements
Exception), so you can catch them broadly or by specific type:
| Exception | When |
|---|---|
KoolbaseConflictException |
A write violates a unique constraint (409). Exposes .field — the field that collided, when the server reports it. |
KoolbaseNotFoundException |
The record or collection doesn't exist (404). |
KoolbaseValidationException |
The request was rejected as invalid (400). |
KoolbasePermissionException |
An access rule denied the operation (403). The session is fine; this caller may not touch that resource. |
KoolbaseUnauthenticatedException |
The server would not accept the caller's credentials (401) — an expired session, a revoked key, or none at all; it does not distinguish. Raised by any surface, since a session stops working for the whole SDK at once. The SDK has already signed the user out by the time you catch this — route to login rather than retrying. |
KoolbaseOfflineBaselineUnavailableException |
An offline update or delete could not be queued: the record has never been read on this device, so there is nothing to apply the change against. Read it first, or make the change online. |
KoolbaseRevisionMismatchException |
The record changed since it was read, and the write was refused rather than overwriting. Carries the server's current version. During offline replay this becomes a conflict instead of surfacing. |
KoolbaseRateLimitException |
The caller is being rate-limited (429). |
KoolbaseVectorDimensionMismatchException |
A vector's length doesn't match the field's declared dimension (400, code vector_dimension_mismatch). |
try {
await Koolbase.db.insert(
collection: 'users',
data: {'email': email},
);
} on KoolbaseConflictException catch (e) {
// e.field is 'email' when the server reports which field clashed
showError('That ${e.field ?? 'value'} is already taken.');
} on KoolbaseUnauthenticatedException {
// Already signed out — the session was cleared before this threw.
goToLogin();
} on KoolbasePermissionException {
showError('You do not have permission to do that.');
} on KoolbaseDataException catch (e) {
// Catch-all for any other data-layer error
showError(e.message);
}
insertonly queues offline on a genuine network failure. A server-side rejection (e.g. a unique conflict) surfaces immediately rather than being silently queued.
All storage failures extend KoolbaseStorageException (which implements
Exception):
| Exception | When |
|---|---|
KoolbaseStorageConflictException |
An upload targets a path that's already taken and overwrite: false (409, code PATH_CONFLICT). Exposes .path — the colliding path. |
KoolbaseStorageNotFoundException |
The bucket or object doesn't exist (404). |
KoolbaseStorageValidationException |
The request was rejected as invalid — bad path, missing field (400). |
KoolbaseStoragePermissionException |
The caller is not allowed to perform the operation (403). |
KoolbaseStorageQuotaExceededException |
An upload would push the bucket past its max_size_bytes cap (409, code QUOTA_EXCEEDED). |
KoolbaseStorageFileTooLargeException |
A single file exceeds the bucket's max_file_size_bytes cap (413, code FILE_TOO_LARGE). |
KoolbaseStorageMimeTypeException |
The upload's content-type isn't in the bucket's allowed_mime_types allowlist (415, code MIME_NOT_ALLOWED). |
try {
await Koolbase.storage.upload(
bucket: 'avatars',
path: 'me.png',
file: file,
);
} on KoolbaseStorageConflictException catch (e) {
// Already exists — prompt the user to confirm overwrite
promptOverwrite(e.path);
} on KoolbaseStoragePermissionException {
showError('You do not have permission to upload here.');
} on KoolbaseStorageException catch (e) {
// Catch-all for any other storage error
showError(e.message);
}A failed invocation raises a type describing what went wrong, so an application can respond to each rather than reading a status code:
| Exception | When |
|---|---|
FunctionNotFoundException |
Nothing is deployed under that name (404). Usually a name mismatch, or deployed to a different project than the app points at. |
FunctionPermissionException |
The caller may not invoke it (403). The credentials were accepted; this caller is not permitted. |
FunctionValidationException |
The Function rejected its arguments (400). |
FunctionExecutionException |
The Function ran and threw. The message is the Function's own. |
FunctionQuotaExceededException |
The project's Function invocations are used up (402). Retrying will not help until the plan allows it. |
All extend FunctionInvokeException, so catching that still covers every one.
A 401 raises KoolbaseUnauthenticatedException instead — a rejected credential
is not a Function failure, and it means the same thing whichever call met it.
Auth methods throw KoolbaseAuthException subtypes — InvalidCredentialsException,
AccountLockedException, EmailAlreadyInUseException, OtpExpiredException,
and so on — also selected from the server's error code.
A session can be invalidated while an app is running — the account was deleted, the token was revoked, or the build was pointed at a different project and the persisted session belongs to the old one.
When that happens the SDK clears the stored session and throws
KoolbaseUnauthenticatedException — from whichever call met it. A query, an
upload, a Function invoke, or a background sync of queued offline writes: a
session stops working for the whole SDK at once, so one handler covers all of
them. authStateChanges emits null, so an app already listening to it routes
to login without doing anything else.
If you know a stored session is stale before making a call — switching projects in a debug build, say — discard it directly:
await Koolbase.auth.clearStoredSession();Unlike logout(), there is no server call: the token has already been refused,
and asking for it to be revoked would only add a round trip that cannot succeed.
Safe in any state, including with no session at all.
The persisted session survives app deletion on iOS, because the Keychain does. A reinstall alone will not clear a stale session.
- Authentication: email + password, Apple Sign-In, Google Sign-In, phone + OTP
- Database with offline-first cache (Drift), realtime subscriptions, populate for related records, semantic search over vectors
- Storage with presigned uploads and downloads, safe-by-default conflict handling, image transforms, object versioning (history + restore + soft-delete)
- Realtime subscriptions over WebSocket
- Authenticated Dart functions (
ctx.authexposes the caller automatically) - Feature flags and remote config
- Version enforcement (force update, soft update)
- Code push — bundle (config + flag overrides + directives + UI) and VM-level Dart code, no store release
- Server-driven UI via Flutter's
rfw— push new screens OTA - Logic engine (conditional flows as data, updatable OTA)
- Analytics (DAU/WAU/MAU, funnels, retention)
- Cloud Messaging (FCM token registration; server-initiated send)
Full documentation at docs.koolbase.com
Manage your projects at app.koolbase.com
MIT