User-owned app databases on native iOS & Android
There’s no Swift or Kotlin SDK — you don’t need one. The database scope is
OAuth 2.1 + PKCE over HTTPS, and the data API is plain JSON, so the platform’s
own crypto and HTTP clients are enough. This page is the whole flow in each
language.
Same three steps as the SDK recipe: build the
authorize URL (with a PKCE challenge), open it, exchange the returned code
for a token scoped to the user’s database, then call /v1.
Swift (iOS)
Section titled “Swift (iOS)”import AuthenticationServicesimport CryptoKitimport Security
let clientId = "psqlrp_…"let redirectUri = "https://app.example.com/auth/callback" // Universal Link
func base64url(_ data: Data) -> String { data.base64EncodedString() .replacingOccurrences(of: "+", with: "-") .replacingOccurrences(of: "/", with: "_") .replacingOccurrences(of: "=", with: "")}
func randomBytes(_ count: Int) -> Data { var bytes = Data(count: count) _ = bytes.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, count, $0.baseAddress!) } return bytes}
struct Grant: Decodable { let accessToken: String let database: String let apiUrl: String let idToken: String? enum CodingKeys: String, CodingKey { case accessToken = "access_token" case database case apiUrl = "api_url" case idToken = "id_token" }}1 + 2 — sign in (PKCE) and capture the code. On iOS 17.4+,
ASWebAuthenticationSession accepts an HTTPS callback directly; on older iOS
handle the Universal Link in your SceneDelegate instead.
let verifier = base64url(randomBytes(32))let challenge = base64url(Data(SHA256.hash(data: Data(verifier.utf8))))let state = base64url(randomBytes(16))
var comps = URLComponents(string: "https://api.persql.com/oauth/authorize")!comps.queryItems = [ .init(name: "response_type", value: "code"), .init(name: "client_id", value: clientId), .init(name: "redirect_uri", value: redirectUri), .init(name: "scope", value: "openid database"), .init(name: "code_challenge", value: challenge), .init(name: "code_challenge_method", value: "S256"), .init(name: "state", value: state),]
let session = ASWebAuthenticationSession( url: comps.url!, callback: .https(host: "app.example.com", path: "/auth/callback")) { callbackURL, error in guard let callbackURL, let items = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?.queryItems, items.first(where: { $0.name == "state" })?.value == state, // CSRF check let code = items.first(where: { $0.name == "code" })?.value else { return } Task { let grant = try await exchange(code: code, verifier: verifier) /* store + use */ }}session.presentationContextProvider = presenter // ASWebAuthenticationPresentationContextProvidingsession.start()3 — exchange the code, then query.
func exchange(code: String, verifier: String) async throws -> Grant { var req = URLRequest(url: URL(string: "https://api.persql.com/oauth/token")!) req.httpMethod = "POST" req.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") var form = URLComponents() form.queryItems = [ .init(name: "grant_type", value: "authorization_code"), .init(name: "code", value: code), .init(name: "client_id", value: clientId), .init(name: "redirect_uri", value: redirectUri), .init(name: "code_verifier", value: verifier), ] req.httpBody = form.percentEncodedQuery?.data(using: .utf8) let (data, _) = try await URLSession.shared.data(for: req) return try JSONDecoder().decode(Grant.self, from: data)}
func query(_ sql: String, _ params: [Any] = [], grant: Grant) async throws -> [String: Any] { // grant.database is "namespace/db", so the path is /v1/db/namespace/db/query var req = URLRequest(url: URL(string: "\(grant.apiUrl)/v1/db/\(grant.database)/query")!) req.httpMethod = "POST" req.setValue("Bearer \(grant.accessToken)", forHTTPHeaderField: "Authorization") req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.httpBody = try JSONSerialization.data(withJSONObject: ["sql": sql, "params": params]) let (data, _) = try await URLSession.shared.data(for: req) let json = try JSONSerialization.jsonObject(with: data) as! [String: Any] return json["data"] as! [String: Any] // { columns, rows, rowsRead, rowsWritten }}Store grant.accessToken (and grant.database) in the Keychain; on later
launches skip the OAuth dance and call query straight away.
Kotlin (Android)
Section titled “Kotlin (Android)”import android.net.Uriimport android.util.Base64import androidx.browser.customtabs.CustomTabsIntentimport okhttp3.FormBodyimport okhttp3.OkHttpClientimport okhttp3.Requestimport okhttp3.RequestBody.Companion.toRequestBodyimport okhttp3.MediaType.Companion.toMediaTypeimport org.json.JSONArrayimport org.json.JSONObjectimport java.security.MessageDigestimport java.security.SecureRandom
const val CLIENT_ID = "psqlrp_…"const val REDIRECT_URI = "https://app.example.com/auth/callback" // App Link
fun b64url(bytes: ByteArray): String = Base64.encodeToString(bytes, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
private val http = OkHttpClient()1 — sign in (PKCE). Keep verifier + state until the redirect returns.
val verifier = b64url(ByteArray(32).also { SecureRandom().nextBytes(it) })val challenge = b64url(MessageDigest.getInstance("SHA-256").digest(verifier.toByteArray()))val state = b64url(ByteArray(16).also { SecureRandom().nextBytes(it) })
val authorize = Uri.parse("https://api.persql.com/oauth/authorize").buildUpon() .appendQueryParameter("response_type", "code") .appendQueryParameter("client_id", CLIENT_ID) .appendQueryParameter("redirect_uri", REDIRECT_URI) .appendQueryParameter("scope", "openid database") .appendQueryParameter("code_challenge", challenge) .appendQueryParameter("code_challenge_method", "S256") .appendQueryParameter("state", state) .build()
CustomTabsIntent.Builder().build().launchUrl(context, authorize)2 — capture the redirect in the Activity registered for the App Link
(intent-filter with android:autoVerify="true" on app.example.com):
override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) val data = intent.data ?: return if (data.getQueryParameter("state") != state) return // CSRF check val code = data.getQueryParameter("code") ?: return lifecycleScope.launch { val grant = exchange(code, verifier) /* store + use */ }}3 — exchange the code, then query (off the main thread):
data class Grant(val accessToken: String, val database: String, val apiUrl: String)
suspend fun exchange(code: String, verifier: String): Grant = withContext(Dispatchers.IO) { val form = FormBody.Builder() .add("grant_type", "authorization_code") .add("code", code) .add("client_id", CLIENT_ID) .add("redirect_uri", REDIRECT_URI) .add("code_verifier", verifier) .build() val req = Request.Builder().url("https://api.persql.com/oauth/token").post(form).build() http.newCall(req).execute().use { res -> val j = JSONObject(res.body!!.string()) Grant(j.getString("access_token"), j.getString("database"), j.optString("api_url", "https://api.persql.com")) }}
suspend fun query(sql: String, params: List<Any?> = emptyList(), grant: Grant): JSONObject = withContext(Dispatchers.IO) { val body = JSONObject().put("sql", sql).put("params", JSONArray(params)) .toString().toRequestBody("application/json".toMediaType()) // grant.database is "namespace/db" → /v1/db/namespace/db/query val req = Request.Builder() .url("${grant.apiUrl}/v1/db/${grant.database}/query") .addHeader("Authorization", "Bearer ${grant.accessToken}") .post(body).build() http.newCall(req).execute().use { res -> JSONObject(res.body!!.string()).getJSONObject("data") // { columns, rows, ... } } }Store the token in EncryptedSharedPreferences; reuse it on later launches.
Batch & tables
Section titled “Batch & tables”Both endpoints follow the same envelope ({ "success": true, "data": … }):
POST /v1/db/{namespace}/{db}/batch— body{ "statements": [{ "sql", "params" }], "transaction": true },datais an array of results.GET /v1/db/{namespace}/{db}/tables—datais[{ "name", "rowCount" }].
The full surface is the REST API reference.