KotlinKtorSpring BootGeolocationTutorial

Kotlin IP Geolocation: Ktor, Spring Boot, and Android Examples

ApogeoAPI6 min read

Data class for the response

// GeoData.kt
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
data class GeoData(
    @SerialName("ip")          val ip: String,
    @SerialName("country_code") val countryCode: String = "US",
    @SerialName("country_name") val countryName: String = "",
    @SerialName("city")         val city: String? = null,
    @SerialName("latitude")     val latitude: Double? = null,
    @SerialName("longitude")    val longitude: Double? = null,
    @SerialName("currency_code") val currencyCode: String? = null,
    @SerialName("timezone")     val timezone: String? = null,
)

Ktor client service

// GeoService.kt (Ktor)
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.serialization.kotlinx.json.*
import java.util.concurrent.ConcurrentHashMap

class GeoService(private val apiKey: String) {
    private val client = HttpClient(CIO) {
        install(ContentNegotiation) { json() }
    }

    // Cache by /24 subnet — reduces API calls ~250×
    private val cache = ConcurrentHashMap<String, Pair<GeoData, Long>>()
    private val ttlMs = 3_600_000L // 1 hour

    suspend fun lookup(ip: String): GeoData {
        val subnet = ip.split(".").take(3).joinToString(".")
        val cached = cache[subnet]
        if (cached != null && System.currentTimeMillis() - cached.second < ttlMs) {
            return cached.first
        }

        val data = client.get("https://api.apogeoapi.com/v1/ip/$ip") {
            bearerAuth(apiKey)
        }.body<GeoData>()

        cache[subnet] = Pair(data, System.currentTimeMillis())
        return data
    }

    fun close() = client.close()
}

Ktor server route

// Application.kt
import io.ktor.server.application.*
import io.ktor.server.response.*
import io.ktor.server.routing.*

fun Application.configureRouting(geoService: GeoService) {
    routing {
        get("/api/pricing") {
            val ip = call.request.headers["X-Forwarded-For"]
                ?.split(",")?.first()?.trim()
                ?: call.request.local.remoteHost

            val geo = geoService.lookup(ip)

            val (currency, amount) = when (geo.countryCode) {
                "US" -> "USD" to 29
                "GB" -> "GBP" to 25
                "DE", "FR", "ES", "IT" -> "EUR" to 27
                "AU", "NZ" -> "AUD" to 45
                "BR" -> "BRL" to 149
                else -> "USD" to 29
            }

            call.respond(mapOf(
                "country"  to geo.countryCode,
                "currency" to currency,
                "amount"   to amount,
                "city"     to geo.city,
                "timezone" to geo.timezone,
            ))
        }
    }
}

Spring Boot WebClient (reactive)

// GeoService.kt (Spring Boot)
import org.springframework.cache.annotation.Cacheable
import org.springframework.stereotype.Service
import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.reactive.function.client.awaitBody

@Service
class GeoService(private val webClient: WebClient) {

    @Cacheable(value = ["geo"], key = "#ip.split('.').take(3).join('.')")
    suspend fun lookup(ip: String): GeoData =
        webClient.get()
            .uri("https://api.apogeoapi.com/v1/ip/{ip}", ip)
            .retrieve()
            .awaitBody()
}

// WebClient bean
@Bean
fun geoWebClient(
    @Value("${apogeoapi.key}") apiKey: String
): WebClient = WebClient.builder()
    .defaultHeader("Authorization", "Bearer $apiKey")
    .build()

Spring Boot controller

@RestController
@RequestMapping("/api")
class GeoController(private val geoService: GeoService) {

    @GetMapping("/geo")
    suspend fun getGeo(
        @RequestHeader(value = "X-Forwarded-For", required = false) xff: String?,
        request: HttpServletRequest
    ): ResponseEntity<GeoData> {
        val ip = xff?.split(",")?.first()?.trim()
            ?: request.remoteAddr
        return ResponseEntity.ok(geoService.lookup(ip))
    }
}

Application.properties

apogeoapi.key=your-api-key-here
spring.cache.type=caffeine
spring.cache.caffeine.spec=maximumSize=1000,expireAfterWrite=3600s

Android (using OkHttp)

For Android apps, you typically call the API from a server (don't embed API keys in the APK). A common pattern is to have your backend expose an endpoint like /api/geo, and the Android app calls that endpoint:

// GeoRepository.kt (Android)
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import okhttp3.Request

class GeoRepository(private val backendUrl: String) {
    private val client = OkHttpClient()
    private val json = Json { ignoreUnknownKeys = true }

    suspend fun fetchCountry(): String = withContext(Dispatchers.IO) {
        val request = Request.Builder()
            .url("$backendUrl/api/geo")
            .build()
        val body = client.newCall(request).execute().body?.string() ?: return@withContext "US"
        json.decodeFromString<GeoData>(body).countryCode
    }
}

// Usage in ViewModel:
// val country = geoRepository.fetchCountry()
// if (country in listOf("BR", "AR", "CO")) showLatamPricing()

Resources

Try ApogeoAPI free

1,000 requests/month forever. 14-day full-access trial. No credit card.

Get your free API key