Part of Github Copilot with Kotlin

Ktor Client: Set up ktor client and get data from typicode

Sandy LaneSandy Lane

Video: Ktor Client: Set up ktor client and get data from typicode by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

Ktor Client: Fetch JSON from a REST API

Build an HTTP client with the CIO engine plus content negotiation. @Serializable data classes for the response shape. client.get(url).body<T>() for the typed call.

JetBrains' Ktor is the standard Kotlin HTTP client — coroutine-based, multiplatform, and pairs cleanly with kotlinx.serialization for typed JSON. Today: fetch a list of posts from JSONPlaceholder.

Dependencies

In build.gradle.kts:

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

dependencies {
  implementation("io.ktor:ktor-client-core:2.3.12")
  implementation("io.ktor:ktor-client-cio:2.3.12")
  implementation("io.ktor:ktor-client-content-negotiation:2.3.12")
  implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.12")
  implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
}

Four Ktor artifacts (core API + CIO engine + content negotiation + JSON serialization) plus the kotlinx.serialization runtime. Don't forget the serialization plugin — without it @Serializable annotations are ignored at compile time.

The Copilot prompt

import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.request.*
import io.ktor.client.call.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json

// Define a Post data class, set up Ktor client with JSON, fetch from typicode

Copilot generates:

@Serializable
data class Post(
  val userId: Int,
  val id: Int,
  val title: String,
  val body: String,
)

fun main() = runBlocking {
  val client = HttpClient(CIO) {
    install(ContentNegotiation) {
      json(Json { ignoreUnknownKeys = true })
    }
  }

  val posts: List<Post> = client.get("https://jsonplaceholder.typicode.com/posts").body()
  posts.take(3).forEach { println(it) }

  client.close()
}

Walkthrough

1. The @Serializable data class. kotlinx.serialization reads the annotation at compile time and generates serialize/deserialize code. No reflection; no runtime cost.

The field names match the API's JSON keys exactly (userId, id, title, body). For mismatches, use @SerialName:

@Serializable
data class Post(
  @SerialName("user_id") val userId: Int,
  // ...
)

2. HttpClient(CIO) { ... }. Build a Ktor client with the CIO (Coroutine-based I/O) engine — pure Kotlin, no Java dependencies, fast for most workloads. Other engines: OkHttp, Apache, Java (Java 11+).

install(ContentNegotiation) { json(...) } plugs in JSON support. Json { ignoreUnknownKeys = true } is forgiving — if the API adds new fields, your code doesn't crash.

3. client.get(url).body(). A typed call. client.get(...) returns an HttpResponse; .body<T>() deserializes the response JSON into T using the registered content negotiator.

The return type is inferred from the variable annotation: val posts: List<Post> = ... .body(). Without the annotation, the compiler doesn't know what type to deserialize into.

4. runBlocking { } for the main function. Ktor's API is suspend-based — client.get is a suspend function. runBlocking is the simplest way to call suspend code from a non-suspend main. For real apps, use a proper coroutine scope (lifecycle-aware on Android, coroutineScope { } in libraries).

5. client.close(). Releases the underlying HTTP/connection pool. Always close when done. For long-lived clients, share one instance and close on app exit.

Adding query parameters

val posts: List<Post> = client.get("https://jsonplaceholder.typicode.com/posts") {
  parameter("userId", 1)
  parameter("limit", 10)
}.body()

parameter(key, value) adds ?userId=1&limit=10 to the URL. Multiple parameter calls accumulate.

Headers and authentication

val response = client.get("https://api.example.com/me") {
  header("Authorization", "Bearer ${token}")
  header("User-Agent", "MyApp/1.0")
  contentType(ContentType.Application.Json)
}

header(key, value) for arbitrary headers. contentType(...) for the standard one (sugar over header("Content-Type", ...)).

For cleaner auth:

val client = HttpClient(CIO) {
  install(Auth) {
    bearer {
      loadTokens {
        BearerTokens(accessToken = "...", refreshToken = "...")
      }
    }
  }
}

The Auth plugin handles Authorization headers automatically.

Posting JSON

val newPost = Post(userId = 1, id = 0, title = "Hi", body = "Hello")

val created: Post = client.post("https://jsonplaceholder.typicode.com/posts") {
  contentType(ContentType.Application.Json)
  setBody(newPost)
}.body()

setBody(value) serializes the value using the registered content negotiator. Same body<T>() pattern to read the response.

Error handling

import io.ktor.client.plugins.*

try {
  val posts: List<Post> = client.get("https://jsonplaceholder.typicode.com/posts").body()
} catch (e: ClientRequestException) {
  println("HTTP ${e.response.status}: ${e.message}")
} catch (e: ServerResponseException) {
  println("Server error ${e.response.status}")
} catch (e: ConnectTimeoutException) {
  println("Connection timeout")
}

Ktor distinguishes:

  • ClientRequestException — 4xx errors.
  • ServerResponseException — 5xx errors.
  • RedirectResponseException — 3xx errors (rare; usually followed automatically).
  • ConnectTimeoutException / HttpRequestTimeoutException — network timeouts.

For app-level retry, install the HttpRequestRetry plugin:

install(HttpRequestRetry) {
  retryOnServerErrors(maxRetries = 3)
  exponentialDelay()
}

Logging

install(Logging) {
  logger = Logger.DEFAULT
  level = LogLevel.INFO
}

Logs all requests and responses. Useful in development; turn off (or set to LogLevel.NONE) in production.

Sharing the client

Don't create a new HttpClient per request — that's expensive (TCP handshakes, TLS setup, thread pool). Share one across the app:

object Api {
  val client = HttpClient(CIO) {
    install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) }
  }

  suspend fun getPosts(): List<Post> =
    client.get("https://jsonplaceholder.typicode.com/posts").body()
}

Or — better — inject via DI (Koin, Hilt) so tests can swap in MockEngine.

Common mistakes

Forgetting the serialization plugin. Compiles, but @Serializable is silently ignored. Runtime: "Serializer for class 'Post' is not found."

Forgetting ignoreUnknownKeys = true. API adds a field → app crashes on parse. Prevention: always allow unknown keys unless strict matching is needed.

Calling body() without a type annotation. val posts = ....body() defaults to String. Always annotate: val posts: List<Post> = ....body().

Not closing the client. Long-running JVMs leak connections. Use one shared client and close on shutdown.

Mismatched JSON keys. API has user_id, your data class has userId, no @SerialName. Parse fails silently with userId = 0.

runBlocking in production code. Fine for main, scripts, demos. In Android UI code, use viewModelScope.launch { ... }. In libraries, take a CoroutineScope or be a suspend function.

What's next

Episode 9: Find the maximum value in a list. A small one-liner that demos maxOrNull() plus the null-safety idioms that make it production-grade.

Recap

Ktor: HttpClient(CIO) { install(ContentNegotiation) { json(...) } }. client.get(url).body<T>() for typed JSON. @Serializable data class with field names matching the API (or @SerialName). Always ignoreUnknownKeys = true for forward compatibility. Share one client; don't recreate per call. runBlocking { ... } only for main/scripts/demos. Errors via ClientRequestException (4xx) and ServerResponseException (5xx).

Next episode: max value in a list.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.