The StitchDB Kotlin Multiplatform client for Android, JVM and iOS: database, auth, storage, realtime, Edge Functions, errors, push, purchases, organizations, flags, jobs, metering, collaborative documents and serverless SQL.
#Install
Kotlin 2.x with coroutines and kotlinx.serialization, plus one Ktor engine. Clone the SDK next to your app and include it in your Gradle build; Gradle builds the modules you use from source:
git clone https://github.com/stitchdb/stitchdb-kotlin// settings.gradle.kts
includeBuild("../stitchdb-kotlin") {
dependencySubstitution {
for (m in listOf("stitchdb-kt", "auth-kt", "postgrest-kt", "storage-kt", "realtime-kt", "functions-kt"))
substitute(module("com.stitchdb:$m")).using(project(":$m"))
}
}// build.gradle.kts
plugins { kotlin("plugin.serialization") version "2.3.21" }
dependencies {
implementation("com.stitchdb:postgrest-kt:1.4.0")
implementation("com.stitchdb:auth-kt:1.4.0")
implementation("com.stitchdb:storage-kt:1.4.0")
implementation("com.stitchdb:realtime-kt:1.4.0")
implementation("com.stitchdb:functions-kt:1.4.0")
implementation("io.ktor:ktor-client-okhttp:3.4.3") // Android/JVM; ktor-client-darwin on iOS, ktor-client-cio anywhere
}The current version is 1.4.0. Push, purchases, organizations, flags and the other app services below are in the core module (stitchdb-kt), which the modules above bring with them.
#Create a client
import com.stitchdb.createStitchClient
import com.stitchdb.auth.Auth
import com.stitchdb.postgrest.Postgrest
import com.stitchdb.storage.Storage
import com.stitchdb.realtime.Realtime
import com.stitchdb.functions.Functions
val stitchdb = createStitchClient(
stitchdbUrl = "https://<project-ref>.p.stitchdb.com",
stitchdbKey = "<anon-key>",
) {
install(Auth)
install(Postgrest)
install(Storage)
install(Realtime)
install(Functions)
}Install only the plugins you use. The session is saved on the device, so users stay signed in between app starts.
#Database
import com.stitchdb.postgrest.from
import com.stitchdb.postgrest.postgrest
import com.stitchdb.postgrest.query.Columns
import com.stitchdb.postgrest.query.Order
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.*
@Serializable
data class Todo(val id: Long? = null, val task: String, val is_complete: Boolean = false)
// Select with filters, order and paging
val todos = stitchdb.from("todos").select(columns = Columns.list("id", "task", "is_complete")) {
filter { eq("is_complete", false) }
order("created_at", Order.DESCENDING)
range(0L..9L)
}.decodeList<Todo>()
// Insert (returning the row), update, upsert, delete
val created = stitchdb.from("todos").insert(Todo(task = "Ship it")) { select() }.decodeSingle<Todo>()
stitchdb.from("todos").update({ set("is_complete", true) }) { filter { eq("id", created.id!!) } }
stitchdb.from("todos").upsert(Todo(id = 1, task = "Ship it today"))
stitchdb.from("todos").delete { filter { eq("id", 1) } }
// Functions
val open = stitchdb.postgrest.rpc("count_open_todos", buildJsonObject { put("owner", userId) }).decodeAs<Int>()Filters: eq, neq, gt, gte, lt, lte, like, ilike, isIn, contains, textSearch, and or { } / and { } blocks. Use Todo::task property references instead of names with the same operators.
#Auth
import com.stitchdb.auth.auth
import com.stitchdb.auth.providers.Github
import com.stitchdb.auth.providers.builtin.Email
import com.stitchdb.auth.providers.builtin.OTP
stitchdb.auth.signUpWith(Email) { email = "[email protected]"; password = "a-strong-password" }
stitchdb.auth.signInWith(Email) { email = "[email protected]"; password = "a-strong-password" }
stitchdb.auth.signInWith(OTP) { email = "[email protected]" } // magic link / code
stitchdb.auth.signInWith(Github) // opens the browser (Android, iOS, desktop)
val user = stitchdb.auth.currentUserOrNull()
val session = stitchdb.auth.currentSessionOrNull()
stitchdb.auth.updateUser { data { put("display_name", "Ada") } }
stitchdb.auth.resetPasswordForEmail("[email protected]")
stitchdb.auth.signOut()
// SessionStatus.Authenticated, NotAuthenticated, Initializing, RefreshFailure
stitchdb.auth.sessionStatus.collect { status -> println(status) }OAuth and magic links return to your app through a deep link: set scheme and host in install(Auth) { } and call stitchdb.handleDeeplinks(intent) in your Android activity. Admin calls (auth.admin.createUserWithEmail { }, retrieveUsers(), deleteUser(id)) need a client created with the service_role key.
#Storage
import com.stitchdb.storage.storage
import kotlin.time.Duration.Companion.seconds
val bucket = stitchdb.storage.from("avatars")
bucket.upload("user-1/avatar.png", bytes) { upsert = true }
val data: ByteArray = bucket.downloadAuthenticated("user-1/avatar.png")
val publicUrl = bucket.publicUrl("user-1/avatar.png") // public buckets
val signedUrl = bucket.createSignedUrl("user-1/avatar.png", 60.seconds) // private
val files = bucket.list("user-1")
bucket.move("user-1/avatar.png", "user-1/old.png")
bucket.delete("user-1/old.png")#Realtime
import com.stitchdb.realtime.*
// Database changes
val changes = stitchdb.channel("todos-changes")
val inserts = changes.postgresChangeFlow<PostgresAction.Insert>(schema = "public") { table = "todos" }
inserts.onEach { println(it.record) }.launchIn(scope)
changes.subscribe()
// Broadcast
val room = stitchdb.channel("room-1") { broadcast { receiveOwnBroadcasts = true } }
room.broadcastFlow<JsonObject>("cursor").onEach { println(it) }.launchIn(scope)
room.subscribe(blockUntilSubscribed = true)
room.broadcast("cursor", buildJsonObject { put("x", 10) })
// Presence
room.presenceChangeFlow().onEach { println(it.joins) }.launchIn(scope)
room.track(buildJsonObject { put("user", "ada") })
stitchdb.realtime.removeChannel(room)#Edge Functions
import com.stitchdb.functions.functions
import io.ktor.client.statement.bodyAsText
val response = stitchdb.functions.invoke("hello-world", body = buildJsonObject { put("name", "Ada") })
println(response.bodyAsText())
val hello = stitchdb.functions.invoke("hello-world").body<Hello>()#Errors
import com.stitchdb.exceptions.RestException
import com.stitchdb.exceptions.HttpRequestException
try {
stitchdb.from("todos").insert(Todo(task = ""))
} catch (e: RestException) { // the API answered with an error
println("${e.statusCode} ${e.error}: ${e.description}")
} catch (e: HttpRequestException) { // network problem or timeout
println(e.message)
}RestException has service subclasses (PostgrestRestException with code, details and hint; AuthRestException with an errorCode) and status subclasses (UnauthorizedRestException, NotFoundRestException, BadRequestRestException). See Errors.
#Hosted sign-in page
import com.stitchdb.auth.HostedPageView
// Opens the page in the browser and returns its URL
stitchdb.auth.signInWithHostedPage(view = HostedPageView.SIGN_UP, lang = "de")Opens the project's hosted sign-in page: sign-in, sign-up, e-mail links and codes, password reset and two-step codes, with no auth screens to build. The user comes back to your deep link; with FlowType.PKCE the sdbl_ code is exchanged when the deep link is handled. view is SIGN_IN, SIGN_UP, MAGIC_LINK or FORGOT_PASSWORD; openUrl = false only returns the URL.
When the project turns on its built-in sign-up check, signUpWith(Email), signUpWith(Phone) and signInWith(OTP) solve it (off the main thread) and send the request again. No captcha widget is needed. The device language goes along as locale in the user metadata (unless data already has locale, language or lang), so auth e-mails are in the user's language.
#Push notifications
import com.stitchdb.push.*
import com.stitchdb.realtime.notifications
// After sign-in and on every new token
stitchdb.push.register(PushDevice.android(fcmToken, topics = listOf("news")))
stitchdb.push.register(PushDevice.ios(apnsTokenBytes)) // the raw APNs token, or its hex
stitchdb.push.subscribe("sale")
// The in-app inbox
val inbox = stitchdb.push.inbox(unread = true) // Inbox(notifications, unread)
stitchdb.push.markRead(listOf(12L, 13L))
stitchdb.push.notifications().collect { showBanner(it.title) } // live, needs the Realtime plugin
// Server side, with the service key
val queued = stitchdb.push.send(PushMessage(userId = id, title = "Your order shipped"))
stitchdb.push.status(queued.id)Methods: register, unregister, subscribe, unsubscribe, inbox, markRead, markAllRead, deleteNotifications, notifications(), config, and with the service key send and status. Devices: PushDevice.android (FCM), PushDevice.ios (APNs), PushDevice.web. The SDK depends on no push library. Errors are PushException. See Push notifications.
#In-app purchases
import com.stitchdb.purchases.PurchaseInput
stitchdb.purchases.sync(PurchaseInput.Google(purchaseToken = purchase.purchaseToken, productId = "pro_monthly"))
stitchdb.purchases.sync(PurchaseInput.Apple(signedTransaction = jws))
if (stitchdb.purchases.hasEntitlement("pro")) unlock()Methods: sync, me (purchases and entitlements), hasEntitlement, config, and with the service key refresh(userId). StitchDB checks each purchase with the store. See In-app purchases.
#Organizations
val org = stitchdb.orgs.create("Acme", slug = "acme")
stitchdb.orgs.invite(org.id, "[email protected]", role = "admin")
stitchdb.orgs.acceptInvite(token) // the invited user
stitchdb.orgs.setActive(org.id) // the session's org_id claim, refreshed for youMethods: create, update, delete, list, members, invite, invites, revokeInvite, acceptInvite, setRole, removeMember, leave, transferOwnership, setActive. Errors are OrgsException. See Organizations.
#Realtime message history
val room = stitchdb.channel("room-1") { history { since = Clock.System.now() - 10.minutes } }
room.broadcastFlow("message").onEach { if (it.replayed) markAsMissed(it) }.launchIn(scope)
room.subscribe()
room.publish("message", buildJsonObject { put("text", "hi") }) // stored, then broadcastAfter every rejoin the broadcasts missed while away come first, in order, then the live ones. RealtimeBroadcast has id and replayed. See Message history.
#Search
import com.stitchdb.search.*
val result = stitchdb.search("posts", "running shoes", limit = 20, mode = SearchMode.HYBRID)
val posts = result.rows<Post>() // best first; result.scores has the ranksModes: HYBRID, KEYWORD, SEMANTIC. See Search.
#Feature flags
stitchdb.flags.load(attributes = mapOf("app_version" to "2.4.1"))
if (stitchdb.flags.isOn("new-checkout")) showNewCheckout()
val limit = stitchdb.flags.get("upload-limit-mb", 10)
stitchdb.flags.changes.collect { keys -> render() }Methods: load, refresh, setAttributes, get, isOn, detail, all, changes, stop, and with the service key evaluate. Flags refresh every minute and on sign-in and sign-out; the last values are kept, so get answers at once on the next start and offline. See Feature flags.
#Background jobs
import com.stitchdb.jobs.JobMode
// Service key, on your server
val run = stitchdb.jobs.start("export", payload = mapOf("table" to "orders"), idempotencyKey = orderId)
val long = stitchdb.jobs.start("render", mode = JobMode.LONG, runAt = Instant.parse("2026-12-01T08:00:00Z"))
stitchdb.jobs.get(run.id).status // queued, running, sleeping, completed, failed, cancelled
stitchdb.jobs.logs(long.id, after = 0)Methods: start, get, list, cancel, retry, logs. See Background jobs.
#Usage metering
import com.stitchdb.metering.UsageGroup
stitchdb.metering.record("ai_tokens", 1532.0, customer = org.id, idempotencyKey = completion.id)
stitchdb.metering.usage(customer = org.id, group = UsageGroup.MONTH)
stitchdb.metering.exportCsv(group = UsageGroup.DAY)Methods: record, recordMany, usage, customers, exportCsv. See Usage metering.
#Collaborative documents
val collab = stitchdb.collab()
val room = collab.open("plan-2026", MyYrsDocument(doc), create = true, title = "Plan 2026")
room.setPresence(buildJsonObject { put("name", "Ann"); put("cursor", 4) })
room.peers.onEach { drawCursors(it) }.launchIn(scope)
room.snapshot("before import")
collab.share("plan-2026", userId, role = "viewer")Yjs documents kept in sync between everyone who has them open. Updates travel as bytes, so any Yjs port works: implement CollabDocument (applyUpdate, encodeStateAsUpdate, mergeUpdates, observeUpdates) over it. The SDK has no CRDT dependency. Edits made offline are sent after reconnecting. Rooms also have snapshots, loadSnapshot, synced, status and close; the client has list, members, unshare and close. See Collaborative documents.
#GraphQL
import com.stitchdb.graphql.graphql
val r = stitchdb.graphql("query { todosCollection(first: 10) { edges { node { id title } } } }")
r.errors.firstOrNull()?.let { error(it.message) }
val data = r.decode<MyData>()Also takes variables and operationName. See GraphQL.
#Status page
val page = stitchdb.status.get() // overall, components with 90 days of history, incidents
stitchdb.status.subscribe("[email protected]")See Status page.
#Serverless SQL
import com.stitchdb.sql.*
val sql = StitchSql(System.getenv("STITCHDB_SQL_URL"))
val todos = sql.query("select * from todos where owner = $1 limit 20", listOf(userId)).decodeRows<Todo>()
sql.transaction(listOf(
SqlQuery("update stock set n = n - 1 where sku = $1", sku),
SqlQuery("insert into orders (sku) values ($1)", sku),
))
// WebSocket session: interactive transactions
sql.connect().use { db ->
db.transaction { query("select balance from accounts where id = $1 for update", listOf(id)) }
}Errors throw SqlException with the Postgres code, detail, constraint and, for a transaction, index. See Serverless driver.