Skip to content

Kotlin & Android Quickstart ​

This gets you from an empty Android project to your first exercises on screen, using Retrofit, OkHttp, and coroutines — the stack most Android apps already run.

If you're on Kotlin Multiplatform, Ktor is the equivalent and the ideas here map directly. For a plain-JVM (non-Android) client with the full sync loop, see the complete ExerciseDb.kt example.

Don't ship your API key in the app

An API key baked into a distributed APK can be extracted — anyone can pull it from the binary. For production, proxy the API through your own backend and keep the key server-side, or issue short-lived per-user tokens from a backend you control. The direct-from-app setup below is right for a prototype, an internal build, or learning the API. See the security note at the end.

1. Get an API key ​

Register and copy your key — see Getting Started for the full flow. In short:

bash
curl -X POST https://api.harshitbishnoi.dev/auth/register \
  -H 'content-type: application/json' \
  -d '{ "email": "you@example.com", "password": "a-long-passphrase", "name": "You" }'

The key comes back once, as apiKey.key. Store it — for now, in local.properties (which is git-ignored), not in source.

2. Add dependencies ​

In your module's build.gradle.kts:

kotlin
plugins {
    kotlin("plugin.serialization") version "2.0.0"
}

dependencies {
    implementation("com.squareup.retrofit2:retrofit:2.11.0")
    implementation("com.squareup.okhttp3:okhttp:4.12.0")
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
    implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
}

Add the internet permission to AndroidManifest.xml:

xml
<uses-permission android:name="android.permission.INTERNET" />

3. Model the response ​

Every success response is a { success, data } envelope; list endpoints add pagination. Model the envelope once and reuse it.

kotlin
import kotlinx.serialization.Serializable

@Serializable
data class Envelope<T>(val data: T, val pagination: Pagination? = null)

@Serializable
data class Pagination(val limit: Int, val offset: Int)

@Serializable
data class ExerciseSummary(
    val id: String,
    val slug: String,
    val name: String,
    val status: String,
    val category: String? = null,
    val difficulty: String,
    val movementPattern: String,
    val tags: List<String> = emptyList(),
    val updatedAt: String
)

Ignore unknown keys

The catalog grows fields over time. Configure the JSON parser with ignoreUnknownKeys = true (step 5) so a new field never crashes an older app build.

4. Declare the API ​

Retrofit turns an interface into an HTTP client. Add the key with an interceptor so you never repeat the header.

kotlin
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Query

interface ExerciseDbApi {
    @GET("exercises")
    suspend fun listExercises(
        @Query("muscle") muscle: String? = null,
        @Query("limit") limit: Int = 20
    ): Envelope<List<ExerciseSummary>>

    @GET("exercises/{id}/substitutes")
    suspend fun substitutes(
        @Path("id") id: String,
        @Query("equipment") equipment: String? = null
    ): Envelope<List<ExerciseSummary>>
}

5. Build the client ​

kotlin
import kotlinx.serialization.json.Json
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
import okhttp3.MediaType.Companion.toMediaType

private const val BASE_URL = "https://api.harshitbishnoi.dev/"

fun createApi(apiKey: String): ExerciseDbApi {
    val json = Json { ignoreUnknownKeys = true }

    val client = OkHttpClient.Builder()
        .addInterceptor(Interceptor { chain ->
            val request = chain.request().newBuilder()
                .header("x-api-key", apiKey)
                .build()
            chain.proceed(request)
        })
        .build()

    return Retrofit.Builder()
        .baseUrl(BASE_URL)
        .client(client)
        .addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
        .build()
        .create(ExerciseDbApi::class.java)
}

6. Make the call ​

From a ViewModel, in a coroutine:

kotlin
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch

class CatalogViewModel(apiKey: String) : ViewModel() {
    private val api = createApi(apiKey)

    fun loadChestExercises() {
        viewModelScope.launch {
            try {
                val page = api.listExercises(muscle = "chest", limit = 10)
                page.data.forEach { println("${it.slug} — ${it.name}") }
            } catch (e: ExerciseDbException) {
                // handle a structured API error — see step 7
            }
        }
    }
}

page.data is your list of summaries; page.pagination carries limit and offset. Fetch the full record (instructions, cues, programming) with a GET exercises/{id} call when the user opens an exercise.

7. Handle errors ​

Every error is RFC 9457 problem+json. Branch on the code member — it's stable; detail is prose that may be reworded.

kotlin
import kotlinx.serialization.Serializable
import retrofit2.HttpException

@Serializable
data class Problem(
    val status: Int,
    val code: String,
    val detail: String,
    val requestId: String? = null
)

class ExerciseDbException(val problem: Problem) : RuntimeException(problem.detail)

// Wrap a call to surface a typed error:
suspend fun <T> ExerciseDbApi.guarded(json: Json, call: suspend () -> T): T =
    try {
        call()
    } catch (e: HttpException) {
        val body = e.response()?.errorBody()?.string().orEmpty()
        throw ExerciseDbException(json.decodeFromString<Problem>(body))
    }
kotlin
if (error.problem.code == "RATE_LIMIT_EXCEEDED") {
    // free tier is 1,000 requests/day; back off until reset
}

Keep problem.requestId in your logs — on a 5xx it's the only handle support can use to find the matching server log line.

8. Use the graph ​

The same client reaches the V2 graph endpoints. Finding substitutes for a user's equipment:

kotlin
val subs = api.substitutes(benchPressId, equipment = "dumbbell,bench")
subs.data.forEach { println(it.name) }

Add @GET methods for exercises/{id}/path, exercises/{id}/progressions, and so on the same way — they all return the summary envelope.

Don't call the API on every screen ​

For a real app, sync the catalog into a local database (Room) once and refresh only what changed — don't hit the network each time a screen opens. The Sync Guide explains the loop, and the full ExerciseDb.kt example implements it end to end, including the two failure modes that silently corrupt a local copy.

Production key handling ​

As flagged at the top: a key shipped in an APK is extractable. For anything public:

  • Proxy through your backend. Your app calls your server; your server holds the ExerciseDB key and forwards the request. The key never leaves your infrastructure.
  • Cache aggressively. The catalog is public and changes rarely, so most reads can be served from your own cache or a synced local copy — cutting both your exposure and your request count.

For a prototype or internal build, the key in local.properties is fine. Just don't ship it to a store build.

Released under the MIT License.