Retrofit: Connect Your Android App to Any REST API, Step by Step

How do you connect an Android app to a REST API? Declare the API as a Kotlin interface with Retrofit annotations — @GET, @POST, @Query, @Path, @Body — and it generates the implementation, parsing JSON via Gson. This guide covers authentication interceptors, timeouts, the IOException-vs-HttpException error split, multipart uploads, parallel requests, and the real error messages with fixes.

Written by Projectech16 min readPublished
For B.E./B.Tech Computer Science and IT final-year students adding server communication to Android apps Topics: Kotlin, Retrofit, OkHttp, Gson, Coroutines
Illustration of a smartphone connected by a data cable to a cloud server with JSON brackets and data packets flowing.
Illustration generated for this guide.
In this guide

Sooner or later every Android project needs data from a server — bus locations, weather, appointment slots, mess menus. Doing that with raw HttpURLConnection means hand-writing URL building, stream reading, JSON parsing, thread management, and error handling for every endpoint. Retrofit replaces all of it with an interface: you declare what the API looks like, and it generates the implementation.

This guide covers the complete Retrofit setup for a student Android app: GET and POST calls, JSON parsing with Gson, authentication headers, timeouts, logging, and the error-handling patterns that keep your app standing when the server misbehaves — with the real error messages you will see and what each one means.

The Retrofit mental model: an interface method is an HTTP request. Annotations describe the request; the return type describes the response.

Setup: the four dependencies

A working Retrofit stack needs four artifacts. Add them to your app module's build.gradle (use the current stable versions from the official docs — the group IDs below are what matter):

dependencies {
    implementation("com.squareup.retrofit2:retrofit:<version>")
    implementation("com.squareup.retrofit2:converter-gson:<version>")
    implementation("com.squareup.okhttp3:logging-interceptor:<version>")
    implementation("com.google.code.gson:gson:<version>")
}

And the one manifest entry students forget until the app crashes on first launch:

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

Without it, every request fails with a SecurityException-flavoured UnknownHostException and you spend an hour blaming the server. Add the permission before writing any networking code.

Build the client once, as a singleton, exactly like the Room database:

object ApiClient {
    private const val BASE_URL = "https://api.example.com/"

    private val logging = HttpLoggingInterceptor().apply {
        level = HttpLoggingInterceptor.Level.BODY
    }

    private val httpClient = OkHttpClient.Builder()
        .addInterceptor(logging)
        .connectTimeout(15, TimeUnit.SECONDS)
        .readTimeout(15, TimeUnit.SECONDS)
        .writeTimeout(15, TimeUnit.SECONDS)
        .build()

    val api: ApiService by lazy {
        Retrofit.Builder()
            .baseUrl(BASE_URL)
            .client(httpClient)
            .addConverterFactory(GsonConverterFactory.create())
            .build()
            .create(ApiService::class.java)
    }
}

Four decisions in this snippet:

  • BASE_URL must end with /. Retrofit throws IllegalArgumentException: baseUrl must end in / otherwise — one of the first errors every student meets.
  • The logging interceptor prints every request and response to Logcat. Invaluable during development; strip it or drop it to Level.BASIC in release builds, because BODY level logs authentication tokens in plaintext.
  • Explicit timeouts. The defaults wait far too long on a dead network. Fifteen seconds for connect/read/write is a sane student default — the user gets an error they can act on instead of an eternal spinner.
  • by lazy builds Retrofit on first use, once. Building a new Retrofit instance per request wastes the connection pool OkHttp maintains underneath.

GET requests: queries and paths

Declare the API as an interface. Two annotations cover most GET usage — @GET with @Query for parameters, @Path for URL segments:

interface ApiService {
    // GET https://api.example.com/buses?route=12A
    @GET("buses")
    suspend fun getBuses(@Query("route") route: String): List<Bus>

    // GET https://api.example.com/buses/MH12AB1234/location
    @GET("buses/{plate}/location")
    suspend fun getBusLocation(@Path("plate") plate: String): BusLocation

    // GET https://api.example.com/weather?city=Pune&units=metric
    @GET("weather")
    suspend fun getWeather(
        @Query("city") city: String,
        @Query("units") units: String = "metric"
    ): WeatherResponse
}

suspend makes each call a coroutine-friendly function: it suspends the coroutine while the network works and resumes with the parsed result. No callbacks, no enqueue. A college bus tracker polling getBusLocation every 30 seconds is a direct application — the polling loop is a while in a coroutine with delay(), cancellable when the screen goes away.

The response classes are plain data classes. Gson maps JSON keys to properties; @SerializedName handles keys that are not valid or idiomatic Kotlin names:

data class WeatherResponse(
    @SerializedName("temp_c") val tempCelsius: Double,
    @SerializedName("condition") val condition: String,
    @SerializedName("humidity") val humidity: Int,
    @SerializedName("last_updated") val lastUpdated: String
)

data class Bus(
    val plate: String,
    val route: String,
    val driverName: String?
)

A weather forecast dashboard is the canonical GET example: one endpoint, a handful of fields, and a UI that maps directly onto the response object.

POST requests: sending data up

// POST https://api.example.com/appointments with a JSON body
@POST("appointments")
suspend fun bookAppointment(@Body request: AppointmentRequest): AppointmentResponse

// Form-encoded POST (login endpoints often expect this)
@FormUrlEncoded
@POST("auth/login")
suspend fun login(
    @Field("username") username: String,
    @Field("password") password: String
): AuthResponse

@Body serialises the data class to JSON via Gson — the same converter, both directions. @FormUrlEncoded with @Field sends application/x-www-form-urlencoded for older login endpoints. A doctor appointment booking app exercises both: JSON bodies for creating appointments, and often a form-encoded or JSON login.

data class AppointmentRequest(
    val doctorId: String,
    val patientName: String,
    val phone: String,
    val slotTime: String  // ISO-8601 string; see the date note below
)

data class AppointmentResponse(
    val bookingId: String,
    val status: String,
    val message: String?
)

Dates: agree on one format with the backend and never deviate. ISO-8601 (2026-09-23T10:30:00+05:30) is the sane choice. Half of all student API bugs are date-format disagreements — "it works in Postman" usually means Postman sent the format the server wanted and the app did not.

Authentication headers: three patterns

Most student backends need a token on every request. Three ways to attach it, in order of preference:

1. Interceptor (preferred) — attaches the token to every request automatically:

class AuthInterceptor(private val tokenProvider: () -> String?) : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val token = tokenProvider()
        val request = if (token != null) {
            chain.request().newBuilder()
                .addHeader("Authorization", "Bearer " + token)
                .build()
        } else {
            chain.request()
        }
        return chain.proceed(request)
    }
}

// add to the OkHttp builder:
.addInterceptor(AuthInterceptor { prefs.getString("token", null) })

2. Per-call @Header — for the occasional endpoint with different auth:

@GET("admin/stats")
suspend fun getAdminStats(@Header("Authorization") auth: String): StatsResponse

3. Static @Headers — for constant headers like API keys:

@Headers("X-Api-Key: your-key-here")
@GET("public/notices")
suspend fun getNotices(): List<Notice>

The interceptor pattern keeps tokens out of your ViewModels and Repositories — the networking layer owns authentication, and a token refresh touches one file. Store the token in EncryptedSharedPreferences rather than plain SharedPreferences if the backend issues long-lived tokens.

Interceptors for cross-cutting concerns

Beyond auth, interceptors handle behaviour every request needs. Two patterns worth knowing:

Token refresh: when the server returns 401, an Authenticator (OkHttp's dedicated hook, distinct from an interceptor) can refresh the token and retry once:

class TokenAuthenticator(
    private val tokenStore: TokenStore
) : Authenticator {
    override fun authenticate(route: Route?, response: Response): Request? {
        // avoid infinite loops: give up if we already retried
        if (response.request.header("Authorization") != null) return null
        val newToken = tokenStore.refreshToken() ?: return null
        return response.request.newBuilder()
            .header("Authorization", "Bearer " + newToken)
            .build()
    }
}

// in the client builder:
.authenticator(TokenAuthenticator(tokenStore))

The return null guards are load-bearing: without the retry check, a permanently-invalid refresh token loops forever. Authenticator runs only on 401/407 challenges, so normal requests never pay for it.

Request IDs for debugging: attach a unique ID to every request so client logs correlate with server logs:

.addInterceptor { chain ->
    val request = chain.request().newBuilder()
        .addHeader("X-Request-Id", UUID.randomUUID().toString())
        .build()
    chain.proceed(request)
}

When a user reports "booking failed at 3 PM," the request ID in your Logcat output finds the exact server-side log line. For a final-year project with a real backend teammate, this is professional-grade debugging for five lines of code.

Error handling: the part students skip

A network call can fail in two fundamentally different ways, and your code must handle both:

viewModelScope.launch {
    _uiState.value = UiState.Loading
    try {
        val buses = repository.getBuses("12A")
        _uiState.value = UiState.Success(buses)
    } catch (e: HttpException) {
        // Server responded with an error status
        _uiState.value = UiState.Error(httpErrorMessage(e.code()))
    } catch (e: IOException) {
        // No response at all: no network, timeout, DNS failure
        _uiState.value = UiState.Error("No connection. Check your network and retry.")
    }
}

fun httpErrorMessage(code: Int): String = when (code) {
    400 -> "Bad request — the app sent data the server rejected."
    401 -> "Session expired. Please log in again."
    403 -> "You do not have permission for this action."
    404 -> "Requested data not found."
    409 -> "Conflict — this slot was just taken. Pick another."
    422 -> "Validation failed. Check the highlighted fields."
    500, 502, 503 -> "Server trouble. Try again in a bit."
    else -> "Unexpected error (code " + code + ")."
}

The IOException vs HttpException split is the single most useful error-handling distinction in Android networking:

Exception Meaning User message
IOException (incl. UnknownHostException, SocketTimeoutException) No usable response — network down, DNS failed, timed out "Check your connection"
HttpException 4xx Server understood the request and refused it Specific to the code (see table above)
HttpException 5xx Server failed internally "Try again later" — retrying immediately rarely helps

For a booking flow, the 409 case is a real product behaviour: two students grabbing the last appointment slot simultaneously. Handling it with a clear message ("this slot was just taken") is the difference between a confusing failure and a graceful one.

Reading error bodies: servers often return JSON error details ({"message": "Slot already booked"}). HttpException.response()?.errorBody()?.string() gives you the raw string — parse it once into an ApiError data class rather than string-matching.

Uploading files: multipart requests

Profile photos, assignment PDFs, site-visit images — file upload is a standard final-year requirement. Retrofit handles it with @Multipart:

@Multipart
@POST("students/{id}/photo")
suspend fun uploadPhoto(
    @Path("id") studentId: String,
    @Part photo: MultipartBody.Part,
    @Part("caption") caption: RequestBody
)

// building the part from a file:
val file = File(photoPath)
val requestFile = file.asRequestBody("image/jpeg".toMediaType())
val photoPart = MultipartBody.Part.createFormData("photo", file.name, requestFile)
val captionBody = "Lab visit, September".toRequestBody("text/plain".toMediaType())

api.uploadPhoto("S123", photoPart, captionBody)

Practical notes: compress images before upload (a 12 MP phone photo is 3–5 MB; resizing to 1280 px wide typically brings it under 300 KB with no visible loss for a thumbnail), and for large files show progress via a custom RequestBody that counts bytes written. Retrofit's suspend functions already move off the main thread, so no manual thread handling is needed.

Customising Gson: dates and leniency

The default GsonConverterFactory.create() uses a stock Gson instance. The most common customisation is the date format:

val gson = GsonBuilder()
    .setDateFormat("yyyy-MM-dd'T'HH:mm:ssZ")  // match the server's format exactly
    .create()

Retrofit.Builder()
    .baseUrl(BASE_URL)
    .addConverterFactory(GsonConverterFactory.create(gson))
    .build()

If the server sometimes returns a number and sometimes a string for the same field (a depressingly common student-backend bug), the clean fix is on the server. The pragmatic client-side fix is a custom JsonDeserializer for that field — document it as tech debt, because it is.

Parallel requests with coroutines

A dashboard that needs the user profile, today's notices, and the mess menu makes three independent calls. Run them concurrently:

viewModelScope.launch {
    try {
        val profile = async { api.getProfile() }
        val notices = async { api.getNotices() }
        val menu = async { api.getMessMenu() }
        _uiState.value = UiState.Success(
            profile.await(), notices.await(), menu.await()
        )
    } catch (e: IOException) {
        _uiState.value = UiState.Error("No connection")
    }
}

async starts all three immediately; await collects the results. Total wait is the slowest call, not the sum. One failure cancels the siblings (structured concurrency) and lands in the single catch block. A hostel mess menu app loading the week's menus for seven days in parallel is a direct application — seven sequential calls feel broken, seven concurrent ones feel instant.

Do not parallelise dependent calls. If call B needs a value from call A, they are sequential by nature — write them sequentially. async is for independent work only.

Debug vs release: switching environments

Student backends live at http://10.0.2.2:8000 (the emulator's alias for the dev machine's localhost) during development and a real domain in production. Hardcoding one URL guarantees a last-minute scramble:

// build.gradle, inside android { }:
buildTypes {
    debug {
        buildConfigField("String", "BASE_URL", "\"http://10.0.2.2:8000/\"")
    }
    release {
        buildConfigField("String", "BASE_URL", "\"https://api.yourproject.in/\"")
    }
}

// in ApiClient:
private const val BASE_URL = BuildConfig.BASE_URL

10.0.2.2 works from the emulator; a physical phone on the same Wi-Fi needs the machine's LAN IP (e.g. http://192.168.1.5:8000/), and cleartext HTTP needs the network-security exception discussed earlier. Keep a one-line note in your README stating which URL each build type uses — future-you, debugging at midnight before the demo, will be grateful.

When Retrofit is not the answer

Retrofit covers request/response HTTP. It does not cover everything:

  • Real-time bidirectional streams (chat, live tracking with sub-second updates) want WebSockets — OkHttp supports them natively, and Retrofit is the wrong abstraction there.
  • GraphQL backends want the Apollo Kotlin client, not Retrofit with hand-built query strings.
  • Firebase Realtime Database / Firestore have their own SDKs with offline caching built in; wrapping them in Retrofit adds nothing.
  • Downloading large files (datasets, videos) is better served by DownloadManager or WorkManager with progress, not a Retrofit call holding a response in memory.

Knowing the boundary is part of the skill: Retrofit for REST APIs, the right tool for everything else.

The error-message field guide

Error Cause Fix
IllegalArgumentException: baseUrl must end in / Base URL missing the trailing slash Add / to the end of BASE_URL
Expected BEGIN_OBJECT but was STRING at line 1 column 1 Gson expected JSON but got something else (often an HTML error page or empty body) Log the raw response with the logging interceptor; check the endpoint in a browser/curl first
Expected BEGIN_ARRAY but was BEGIN_OBJECT Declared List<X> but the API wraps the array in an object ({"data": [...]}) Add a wrapper data class matching the actual JSON shape
Unable to resolve host "...": No address associated with hostname DNS failure — usually no network, sometimes a typo in the host Check connectivity and the URL spelling; confirm INTERNET permission
Cleartext HTTP traffic to ... not permitted Used http:// on Android 9+ (API 28), which blocks cleartext by default Switch to https://, or add a network-security-config (prefer HTTPS)
java.net.SocketTimeoutException: timeout Server did not respond within the configured timeout Check server load; confirm the timeout values; retry with backoff
401 Unauthorized on every call after login Token not attached, expired, or in the wrong header format Verify the interceptor runs; check Bearer prefix spelling; confirm token storage
Non-static method cannot be referenced / converter errors at build Mismatched Retrofit/converter versions Keep retrofit, converter-gson, and the OkHttp logging interceptor on compatible versions

Debugging workflow: Logcat first, code second

When a call fails, work in this order:

  1. Read the logging interceptor output in Logcat. It shows the exact URL, headers, request body, status code, and response body. Roughly 80% of Retrofit problems are visible here — wrong URL, wrong body shape, server returning HTML.

  2. Reproduce outside the app. Paste the request into curl or Postman with the same headers. If it fails there too, the problem is the request or the server, not your Android code.

  3. Validate the JSON shape. Compare the actual response keys against your data class field names and @SerializedName values, key by key. One mismatched key nulls one field silently (or crashes parsing, depending on nullability).

  4. Check nullability. A JSON field that is sometimes absent must map to a nullable Kotlin property. Non-null property + missing key = crash at parse time.

  5. Suspect stale state, not just the network. If a call "works sometimes," check whether an old response is cached somewhere — an OkHttp cache, a repository holding a stale list, or the emulator's DNS cache after switching networks. Reproduce on a fresh install before concluding the server is flaky.

Never ship Level.BODY logging in a release build. It writes tokens, passwords, and personal data to Logcat, where any app with log access on a rooted device — or a bug report attachment — can read them. Gate it behind BuildConfig.DEBUG.

Timeouts, retries, and politeness

  • Timeouts (set in the client builder above) bound how long one attempt waits. Fifteen seconds is generous; for a snappy campus app, 10 seconds connect / 15 seconds read is reasonable.
  • Retries do not belong in an interceptor for POST/PUT requests — retrying a booking POST can create duplicate bookings. Retry idempotent GETs with exponential backoff (WorkManager or a manual delay loop); surface POST failures to the user with a retry button instead.
  • Polling (bus location every 30 seconds) belongs in a coroutine tied to the screen's lifecycle, cancelled in onCleared/onStop. Polling from a global scope drains battery and data after the user leaves the screen.

Testing without a server

Retrofit's MockWebServer (a separate test dependency) serves canned responses to your real Retrofit client:

@Test
fun `getBuses parses the bus list`() = runTest {
    server.enqueue(MockResponse().setBody(
        "[{\"plate\":\"MH12AB1234\",\"route\":\"12A\",\"driverName\":null}]"
    ))
    val buses = api.getBuses("12A")
    assertEquals(1, buses.size)
    assertEquals("MH12AB1234", buses[0].plate)
}

This tests your parsing and mapping against JSON you control — no backend needed, no flakiness. Write one test per endpoint before the backend exists, using the API contract you agreed on; when the real server arrives, the tests verify the contract held.

Checklist

  • INTERNET permission in the manifest; Retrofit singleton built once with by lazy.
  • BASE_URL ends with /; timeouts configured explicitly (not defaults).
  • Logging interceptor at BODY for debug builds, reduced or removed for release.
  • Auth token attached via interceptor; token stored securely, not hardcoded.
  • Every call site handles IOException (no network) and HttpException (server error) separately, with user-facing messages per status-code family.
  • Date format agreed with the backend (ISO-8601) and used consistently.
  • POST retries are user-initiated, never automatic; polling is lifecycle-bound.
  • One MockWebServer test per endpoint, written against the agreed API contract.
  • R8/ProGuard keep rules added for Retrofit service interfaces and Gson data classes — release builds with minification can otherwise strip or rename the fields Gson needs, breaking parsing only in the build you demo.

Where this fits your project

Retrofit is the network half of the standard student stack: Room holds local data, Retrofit moves it to and from the server, an MVVM layout keeps the layers separated, and FCM delivers server-initiated updates. A bus tracker exercises polling GETs; a weather dashboard exercises response mapping; an appointment booking app exercises authenticated POSTs and 409 conflict handling. For the server side of the same conversation — MQTT, HTTP, and Firebase options — the cloud-connection guide covers the backend choices. More Android builds live in the Android branch hub.

More project guides

More in Android