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
- ApogeoAPI IP Geolocation — free tier, coroutine-friendly
- Ktor client docs
- Spring WebClient docs
Try ApogeoAPI free
1,000 requests/month forever. 14-day full-access trial. No credit card.
Get your free API key