Kotlin Guide

Detect Website Tech Stacks in Kotlin with StackPeek API

Published March 29, 2026 · 9 min read

Kotlin has become the default language for Android development and is rapidly gaining ground on the server side through Spring Boot, Ktor, and Kotlin Multiplatform. If you are building a competitive intelligence tool, a sales prospecting pipeline, or a security scanner in Kotlin, you need a reliable way to detect which technologies a website is running.

The StackPeek API gives you exactly that: send a URL, get back a structured JSON response listing every detected framework, CMS, analytics tool, CDN, and hosting provider. No browser automation, no headless Chrome, no Selenium grids. Just a single HTTP call.

This guide walks through real Kotlin code for every major HTTP client in the ecosystem: OkHttp for straightforward JVM projects, Ktor client with kotlinx.serialization for coroutine-native workflows, and Spring Boot for enterprise applications. We will also cover batch scanning with coroutines and data class modeling for the API response.

API Response Data Classes

Before writing any HTTP code, define Kotlin data classes that map to the StackPeek JSON response. This gives you type safety throughout your codebase and makes IDE auto-complete useful.

import kotlinx.serialization.Serializable

@Serializable
data class TechDetection(
    val name: String,
    val category: String,
    val confidence: Double,
    val version: String? = null,
    val website: String? = null
)

@Serializable
data class StackPeekResponse(
    val url: String,
    val technologies: List<TechDetection>,
    val scanTime: Long? = null
)

These classes work with both kotlinx.serialization and Gson. If you are using Gson or Moshi instead, drop the @Serializable annotation and add the appropriate annotations for your library. The structure stays the same.

Quick Start with OkHttp

OkHttp is the most widely-used HTTP client on the JVM and Android. If your project already depends on it (most Android projects do), this is the fastest path to calling the StackPeek API.

Gradle dependency

// build.gradle.kts
dependencies {
    implementation("com.squareup.okhttp3:okhttp:4.12.0")
    implementation("com.google.code.gson:gson:2.11.0")
}

Detect a single URL

import com.google.gson.Gson
import okhttp3.OkHttpClient
import okhttp3.Request
import java.net.URLEncoder
import java.util.concurrent.TimeUnit

data class TechDetection(
    val name: String,
    val category: String,
    val confidence: Double,
    val version: String? = null
)

data class StackPeekResponse(
    val url: String,
    val technologies: List<TechDetection>
)

fun detectTechStack(targetUrl: String): StackPeekResponse? {
    val client = OkHttpClient.Builder()
        .connectTimeout(15, TimeUnit.SECONDS)
        .readTimeout(30, TimeUnit.SECONDS)
        .build()

    val encoded = URLEncoder.encode(targetUrl, "UTF-8")
    val apiUrl = "https://us-central1-todd-agent-prod.cloudfunctions.net" +
        "/stackpeekApi/api/v1/detect?url=$encoded"

    val request = Request.Builder()
        .url(apiUrl)
        .header("Accept", "application/json")
        .get()
        .build()

    client.newCall(request).execute().use { response ->
        if (!response.isSuccessful) {
            println("API error: ${response.code}")
            return null
        }
        val body = response.body?.string() ?: return null
        return Gson().fromJson(body, StackPeekResponse::class.java)
    }
}

fun main() {
    val result = detectTechStack("https://linear.app")
    result?.technologies?.forEach { tech ->
        println("${tech.name} (${tech.category}) — ${tech.confidence}%")
    }
}

This prints each detected technology with its category and confidence score. OkHttp handles connection pooling and retry logic internally, so this works well for moderate-volume scanning without additional configuration.

Ktor Client with kotlinx.serialization

If you prefer a coroutine-native HTTP client that feels idiomatic to Kotlin, Ktor is the way to go. The combination of Ktor client and kotlinx.serialization eliminates manual JSON parsing entirely.

Gradle dependencies

// build.gradle.kts
plugins {
    kotlin("plugin.serialization") version "1.9.22"
}

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

Client setup and detection

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 kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json

@Serializable
data class TechDetection(
    val name: String,
    val category: String,
    val confidence: Double,
    val version: String? = null,
    val website: String? = null
)

@Serializable
data class StackPeekResponse(
    val url: String,
    val technologies: List<TechDetection>,
    val scanTime: Long? = null
)

val stackPeekClient = HttpClient(CIO) {
    install(ContentNegotiation) {
        json(Json {
            ignoreUnknownKeys = true
            isLenient = true
        })
    }
    engine {
        requestTimeout = 30_000
    }
}

suspend fun detectTechStack(targetUrl: String): StackPeekResponse {
    return stackPeekClient.get(
        "https://us-central1-todd-agent-prod.cloudfunctions.net" +
            "/stackpeekApi/api/v1/detect"
    ) {
        parameter("url", targetUrl)
        header("Accept", "application/json")
    }.body()
}

suspend fun main() {
    val result = detectTechStack("https://stripe.com")

    println("Technologies detected on ${result.url}:")
    result.technologies
        .sortedByDescending { it.confidence }
        .forEach { tech ->
            val ver = tech.version?.let { " v$it" } ?: ""
            println("  ${tech.name}$ver — ${tech.category} (${tech.confidence}%)")
        }

    stackPeekClient.close()
}

The ContentNegotiation plugin with kotlinx.serialization handles all JSON deserialization. The ignoreUnknownKeys = true setting is important because it prevents your code from breaking if StackPeek adds new fields to the response in the future. The entire flow is suspend-based, so it integrates cleanly with any coroutine scope.

Spring Boot @Service with Caching

For enterprise Kotlin projects running on Spring Boot, you want a proper service layer with dependency injection and caching. This pattern lets you inject the tech detection capability into any controller, scheduled job, or message listener.

Gradle dependencies

// build.gradle.kts
dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.boot:spring-boot-starter-cache")
    implementation("com.github.ben-manes.caffeine:caffeine:3.1.8")
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
}

Service implementation

import org.springframework.cache.annotation.Cacheable
import org.springframework.stereotype.Service
import org.springframework.web.client.RestTemplate
import org.springframework.web.util.UriComponentsBuilder

data class TechDetection(
    val name: String = "",
    val category: String = "",
    val confidence: Double = 0.0,
    val version: String? = null
)

data class StackPeekResponse(
    val url: String = "",
    val technologies: List<TechDetection> = emptyList()
)

@Service
class TechStackService(
    private val restTemplate: RestTemplate
) {
    companion object {
        private const val BASE_URL =
            "https://us-central1-todd-agent-prod.cloudfunctions.net" +
            "/stackpeekApi/api/v1/detect"
    }

    @Cacheable(value = ["techStacks"], key = "#targetUrl")
    fun detect(targetUrl: String): StackPeekResponse? {
        val uri = UriComponentsBuilder.fromUriString(BASE_URL)
            .queryParam("url", targetUrl)
            .build()
            .toUri()

        return restTemplate.getForObject(uri, StackPeekResponse::class.java)
    }

    @Cacheable(value = ["techStacks"], key = "#targetUrl")
    fun detectFrameworks(targetUrl: String): List<TechDetection> {
        val response = detect(targetUrl) ?: return emptyList()
        return response.technologies.filter {
            it.category in listOf("framework", "frontend", "backend", "cms")
        }
    }

    fun usesWordPress(targetUrl: String): Boolean {
        val techs = detect(targetUrl)?.technologies ?: return false
        return techs.any { it.name.equals("WordPress", ignoreCase = true) }
    }
}

Cache configuration

import com.github.benmanes.caffeine.cache.Caffeine
import org.springframework.cache.CacheManager
import org.springframework.cache.annotation.EnableCaching
import org.springframework.cache.caffeine.CaffeineCacheManager
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.web.client.RestTemplate
import java.util.concurrent.TimeUnit

@Configuration
@EnableCaching
class AppConfig {

    @Bean
    fun restTemplate(): RestTemplate = RestTemplate()

    @Bean
    fun cacheManager(): CacheManager {
        val manager = CaffeineCacheManager("techStacks")
        manager.setCaffeine(
            Caffeine.newBuilder()
                .maximumSize(5_000)
                .expireAfterWrite(24, TimeUnit.HOURS)
                .recordStats()
        )
        return manager
    }
}

REST controller

import org.springframework.web.bind.annotation.*

@RestController
@RequestMapping("/api/tech")
class TechStackController(
    private val techStackService: TechStackService
) {
    @GetMapping("/detect")
    fun detect(@RequestParam url: String): StackPeekResponse? {
        return techStackService.detect(url)
    }

    @GetMapping("/frameworks")
    fun frameworks(@RequestParam url: String): List<TechDetection> {
        return techStackService.detectFrameworks(url)
    }

    @GetMapping("/uses-wordpress")
    fun usesWordPress(@RequestParam url: String): Map<String, Boolean> {
        return mapOf("wordpress" to techStackService.usesWordPress(url))
    }
}

The @Cacheable annotation caches results by URL for 24 hours using Caffeine. Technology stacks rarely change daily, so this dramatically reduces API calls while keeping your data fresh enough. The TechStackController exposes the detection as your own REST API, which is useful if you are building a SaaS product that aggregates tech intelligence for customers.

Coroutine-Based Batch Scanning

The real power of Kotlin shows up when you need to scan hundreds or thousands of domains. Coroutines let you run many API calls concurrently without the complexity of managing thread pools manually.

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 kotlinx.coroutines.*
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json

@Serializable
data class TechDetection(
    val name: String,
    val category: String,
    val confidence: Double,
    val version: String? = null
)

@Serializable
data class StackPeekResponse(
    val url: String,
    val technologies: List<TechDetection>
)

data class ScanResult(
    val domain: String,
    val response: StackPeekResponse?,
    val error: String? = null
)

class BatchScanner(
    private val concurrency: Int = 10
) {
    private val client = HttpClient(CIO) {
        install(ContentNegotiation) {
            json(Json { ignoreUnknownKeys = true })
        }
        engine {
            requestTimeout = 30_000
        }
    }

    private val semaphore = Semaphore(concurrency)

    private suspend fun scanOne(domain: String): ScanResult {
        return try {
            semaphore.withPermit {
                val response: StackPeekResponse = client.get(
                    "https://us-central1-todd-agent-prod.cloudfunctions.net" +
                        "/stackpeekApi/api/v1/detect"
                ) {
                    parameter("url", "https://$domain")
                }.body()
                ScanResult(domain, response)
            }
        } catch (e: Exception) {
            ScanResult(domain, null, error = e.message)
        }
    }

    suspend fun scan(domains: List<String>): List<ScanResult> {
        return coroutineScope {
            domains.map { domain ->
                async { scanOne(domain) }
            }.awaitAll()
        }
    }

    fun close() {
        client.close()
    }
}

suspend fun main() {
    val domains = listOf(
        "stripe.com",
        "linear.app",
        "vercel.com",
        "notion.so",
        "figma.com",
        "github.com",
        "shopify.com",
        "slack.com"
    )

    val scanner = BatchScanner(concurrency = 5)
    val startTime = System.currentTimeMillis()

    val results = scanner.scan(domains)
    val elapsed = System.currentTimeMillis() - startTime

    println("Scanned ${results.size} domains in ${elapsed}ms\n")

    results.forEach { result ->
        if (result.error != null) {
            println("${result.domain}: ERROR — ${result.error}")
        } else {
            val techs = result.response?.technologies
                ?.sortedByDescending { it.confidence }
                ?.take(5)
                ?.joinToString(", ") { it.name }
            println("${result.domain}: $techs")
        }
    }

    scanner.close()
}

The Semaphore limits concurrency to a configurable number of parallel requests. This prevents overwhelming the API while still scanning far faster than sequential calls. With a concurrency of 5, you can scan 100 domains in roughly 20 seconds instead of the 5+ minutes it would take sequentially.

The async { ... }.awaitAll() pattern launches all scans concurrently (gated by the semaphore) and waits for every result before returning. Each scan is independent, so a failure on one domain does not affect the others.

Filtering Results by Category

Once you have the raw detection data, you often need to filter or group technologies by category. Here are some utility extensions that make this ergonomic in Kotlin:

fun StackPeekResponse.frontendFrameworks(): List<TechDetection> =
    technologies.filter { it.category in listOf("framework", "frontend") }

fun StackPeekResponse.cmsDetected(): TechDetection? =
    technologies.firstOrNull { it.category == "cms" }

fun StackPeekResponse.analyticsTools(): List<TechDetection> =
    technologies.filter { it.category == "analytics" }

fun StackPeekResponse.hostingProvider(): TechDetection? =
    technologies.firstOrNull { it.category in listOf("hosting", "cdn", "paas") }

fun StackPeekResponse.toSummaryMap(): Map<String, List<String>> =
    technologies.groupBy({ it.category }, { it.name })

// Usage
val result = detectTechStack("https://shopify.com")
val cms = result.cmsDetected()
println("CMS: ${cms?.name ?: "None detected"}")
println("Frontend: ${result.frontendFrameworks().joinToString { it.name }}")
println("Summary: ${result.toSummaryMap()}")

Extension functions are one of Kotlin's best features for this kind of domain-specific API. They keep your business logic readable without modifying the data classes themselves.

Pricing: StackPeek vs Wappalyzer

If you have evaluated Wappalyzer's API for a Kotlin project, you have probably noticed the pricing. Here is how the two compare:

Feature StackPeek Wappalyzer
Free tier 100 scans/day 50 scans/month
Paid plan (entry) $9/month $250/month
Scans on entry plan 5,000/month 50,000/month
Cost per scan $0.0018 $0.005
API key required No (free tier) Yes
Technologies detected 120+ 1,400+
Response format JSON JSON

For most Kotlin projects — lead generation tools, competitive analysis dashboards, security scanners — you need to detect the major frameworks, CMS platforms, and analytics tools. StackPeek covers these at a fraction of the cost. If you need to identify niche JavaScript libraries or obscure WordPress plugins, Wappalyzer's broader fingerprint database may justify the premium. But for 90% of use cases, StackPeek at $9/month vs Wappalyzer at $250/month is the clear winner.

Use Cases for Kotlin Developers

Here are the most common scenarios where Kotlin developers use tech stack detection:

Start detecting tech stacks from Kotlin today

100 free scans/day. No API key required. JSON response ready for kotlinx.serialization.

Try StackPeek Free →

Getting Started in 60 Seconds

The fastest way to test StackPeek from Kotlin is with a simple script. No API key, no signup, no configuration:

  1. Add OkHttp and Gson to your build.gradle.kts
  2. Copy the OkHttp example from above into a main.kt file
  3. Run it: kotlin main.kt
  4. See the detected technologies printed to your console

From there, swap in Ktor if you want coroutine-native code, or wrap it in a Spring Boot service if you need caching and dependency injection. The API is the same regardless of which HTTP client you use — it is just a GET request with a URL parameter.

For production use, add caching (Caffeine for Spring, an in-memory map for standalone apps), error handling with retries, and concurrency limits with Semaphore. The code examples in this guide cover all of these patterns.