Kotlin Screenshot API Integration

Capture website screenshots from Kotlin using OkHttp or ktor-client. Coroutine-based API calls, Ktor server proxy, batch capture with async. Free 200 captures/month.

Vitalii Holben Updated

ScreenshotRun's REST API gives Kotlin developers a way to capture webpages as PNG, JPEG, WebP, AVIF, or PDF. One call through OkHttp or ktor-client, and the image bytes land in your code. You skip the Chromium dependency entirely, no browser binaries to ship, no driver version mismatches on deploy.

Selenium and Playwright handle screenshots on the JVM, but they bring Chrome along. That means fonts, shared memory tuning, and a 1+ GB Docker layer for the browser alone. On Android, headless Chrome is off the table entirely. OkHttp avoids all of it because the rendering happens on ScreenshotRun's servers.

Capture your first screenshot

Sign up at the dashboard. The API key is on your settings page once you confirm your email. The free tier covers 200 captures monthly, no payment details needed.

Add OkHttp to your build.gradle.kts:

dependencies {
    implementation("com.squareup.okhttp3:okhttp:4.12.0")
}

Then capture a page:

import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.HttpUrl.Companion.toHttpUrl
import java.io.File

fun main() {
    val client = OkHttpClient()

    val url = "https://api.screenshotrun.com/v1/screenshots/capture"
        .toHttpUrl()
        .newBuilder()
        .addQueryParameter("url", "https://github.com")
        .addQueryParameter("format", "png")
        .addQueryParameter("response_type", "image")
        .build()

    val request = Request.Builder()
        .url(url)
        .header("Authorization", "Bearer YOUR_API_KEY")
        .build()

    client.newCall(request).execute().use { response ->
        check(response.isSuccessful) { "Capture failed: ${response.code}" }

        val bytes = response.body!!.bytes()
        File("github.png").writeBytes(bytes)
        println("Saved: github.png (${bytes.size / 1024} KB)")
    }
}

OkHttp handles connection pooling and HTTP/2 out of the box. Response compression works automatically too. The API renders the page in a headless Chrome instance on our servers, waits for JavaScript to finish running and lazy-loaded images to appear, then streams the result back as raw bytes.

Coroutine-based screenshot function with ktor-client

If your project already uses Kotlin coroutines, ktor-client fits better than OkHttp. It suspends instead of blocking a thread, which matters when you capture multiple pages concurrently.

Add the dependency:

dependencies {
    implementation("io.ktor:ktor-client-cio:2.3.12")
}

A reusable suspend function:

import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.request.*
import io.ktor.client.statement.*

suspend fun captureScreenshot(
    client: HttpClient,
    apiKey: String,
    targetUrl: String,
    format: String = "png",
    width: Int = 1280,
    fullPage: Boolean = false,
): ByteArray {
    val response = client.get("https://api.screenshotrun.com/v1/screenshots/capture") {
        header("Authorization", "Bearer $apiKey")
        parameter("url", targetUrl)
        parameter("format", format)
        parameter("width", width)
        parameter("full_page", fullPage)
        parameter("response_type", "image")
    }

    check(response.status.value in 200..299) {
        "Screenshot failed: HTTP ${response.status.value}"
    }

    return response.readBytes()
}

Call it from a coroutine scope:

import kotlinx.coroutines.runBlocking
import java.io.File

fun main() = runBlocking {
    val client = HttpClient(CIO)
    val apiKey = System.getenv("SCREENSHOTRUN_KEY")

    val screenshot = captureScreenshot(
        client = client,
        apiKey = apiKey,
        targetUrl = "https://github.com",
        format = "webp",
        width = 1920,
        fullPage = true,
    )

    File("github-full.webp").writeBytes(screenshot)
    println("Full-page WebP saved: github-full.webp (${screenshot.size / 1024} KB)")

    client.close()
}

The suspend keyword means this function yields the thread while waiting for the HTTP response. You can fire off ten captures with async { } and they all run on the same thread pool without blocking.

Batch capture with coroutines

Kotlin's structured concurrency makes parallel captures straightforward. Capture a list of URLs at once and collect the results:

import kotlinx.coroutines.*
import java.io.File

suspend fun captureBatch(
    client: HttpClient,
    apiKey: String,
    urls: List<String>,
) = coroutineScope {
    urls.map { url ->
        async {
            val bytes = captureScreenshot(client, apiKey, url)
            val filename = url
                .removePrefix("https://")
                .replace("/", "_")
                .take(50) + ".png"
            File(filename).writeBytes(bytes)
            println("Done: $filename")
        }
    }.awaitAll()
}

fun main() = runBlocking {
    val client = HttpClient(CIO)
    val apiKey = System.getenv("SCREENSHOTRUN_KEY")

    val urls = listOf(
        "https://github.com",
        "https://stackoverflow.com",
        "https://kotlinlang.org",
    )

    captureBatch(client, apiKey, urls)
    client.close()
}

Each URL launches a separate coroutine through async. The API handles the rendering in parallel on our side, so a batch of three URLs finishes in roughly the same time as a single capture. Rate limits still apply per your plan, but the free tier allows enough concurrency for most batch jobs.

Ktor server proxy endpoint

In a Ktor backend, wrapping the API behind your own route hides the key from browsers. The frontend calls your endpoint; your server adds credentials and forwards the request.

import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*

fun main() {
    val apiKey = System.getenv("SCREENSHOTRUN_KEY")
    val httpClient = HttpClient(CIO)

    embeddedServer(Netty, port = 8080) {
        routing {
            get("/api/screenshot") {
                val targetUrl = call.parameters["url"]
                    ?: return@get call.respondText("Missing url", status = HttpStatusCode.BadRequest)

                val format = call.parameters["format"] ?: "png"
                val width = call.parameters["width"] ?: "1280"

                val apiResponse = httpClient.get(
                    "https://api.screenshotrun.com/v1/screenshots/capture"
                ) {
                    header("Authorization", "Bearer $apiKey")
                    parameter("url", targetUrl)
                    parameter("format", format)
                    parameter("width", width)
                    parameter("response_type", "image")
                }

                if (!apiResponse.status.isSuccess()) {
                    call.respondText("Capture failed", status = HttpStatusCode.fromValue(apiResponse.status.value))
                    return@get
                }

                val bytes = apiResponse.readBytes()
                val contentType = when (format) {
                    "jpeg" -> ContentType.Image.JPEG
                    "webp" -> ContentType("image", "webp")
                    "png" -> ContentType.Image.PNG
                    else -> ContentType.Image.PNG
                }

                call.respondBytes(bytes, contentType)
            }
        }
    }.start(wait = true)
}

Your frontend hits /api/screenshot?url=https://example.com&format=png and gets the image directly. The API key stays in the server process. Add Ktor's caching plugin to skip repeat captures for the same URL.

API parameters

Pass each option as a query parameter. Here are the ones Kotlin projects use most often:

ParameterTypeDefaultEffect
urlStringrequiredTarget page to render
formatStringpngChoose between png, jpeg, webp, avif, or pdf output
widthInt1280Rendered page width in pixels
heightInt720Rendered page height in pixels
full_pageBooleanfalseCapture below the fold by scrolling to the bottom and stitching
deviceStringdesktopSimulates a desktop, phone, or tablet screen size
retinaBooleanfalseProduces a Retina-quality image at twice the resolution
dark_modeBooleanfalseSwitch the page to its dark theme if one exists
selectorStringCSS selector to clip to one element
delayInt0Extra wait time (ms) for JS-heavy pages to settle
block_cookiesBooleanfalseStrip cookie consent dialogs
block_adsBooleanfalseRemove ads before the page is captured
block_chatsBooleanfalseRemove support chat overlays
cssStringYour own CSS rules, loaded before the screenshot runs
jsStringCustom script that runs once the DOM is ready

Every parameter is documented at the API reference, including response structures and error codes.

Rendering tweaks: dark theme, element clipping, and injected stylesheets

Dark mode

The dark_mode flag injects a prefers-color-scheme: dark media override before Chrome loads the target URL. GitHub, Notion, and most modern sites flip to their dark palette:

val response = client.get("https://api.screenshotrun.com/v1/screenshots/capture") {
    header("Authorization", "Bearer $apiKey")
    parameter("url", "https://github.com")
    parameter("dark_mode", true)
    parameter("response_type", "image")
}

Element capture

Target a single element by its CSS selector. The API trims everything outside that element's bounding rectangle:

parameter("url", "https://github.com/jetbrains")
parameter("selector", ".js-repo-list")
parameter("response_type", "image")

CSS injection

Inject arbitrary CSS to strip promo overlays, override typefaces, or adjust spacing before the renderer fires:

parameter("url", "https://example.com")
parameter("css", ".promo-banner { display: none !important; }")
parameter("response_type", "image")

Combine them freely. One parameter() chain can switch to dark mode, clip to a selector, and inject a stylesheet, all in a single HTTP round trip.

Retry logic for production Kotlin apps

OkHttp and ktor-client throw on connection failures, but HTTP-level problems need explicit handling. Rate limiting, target-page timeouts, upstream 5xx. Here is a coroutine-based retry function:

import io.ktor.client.statement.*
import kotlinx.coroutines.delay

suspend fun captureWithRetry(
    client: HttpClient,
    apiKey: String,
    targetUrl: String,
    maxRetries: Int = 2,
): ByteArray {
    repeat(maxRetries + 1) { attempt ->
        val response = client.get("https://api.screenshotrun.com/v1/screenshots/capture") {
            header("Authorization", "Bearer $apiKey")
            parameter("url", targetUrl)
            parameter("format", "png")
            parameter("response_type", "image")
        }

        val status = response.status.value

        if (status in 200..299) {
            return response.readBytes()
        }

        if (status == 429) {
            val wait = response.headers["Retry-After"]?.toLongOrNull() ?: 5L
            delay(wait * 1000)
            return@repeat
        }

        if (status >= 500 && attempt < maxRetries) {
            delay((attempt + 1) * 1000L)
            return@repeat
        }

        error("Screenshot failed: HTTP $status")
    }

    error("Max retries exceeded")
}

When the server replies 429, Retry-After tells the coroutine how long to suspend. A 5xx gets a second chance after a short backoff. Anything in the 4xx range (bad key, malformed URL) surfaces immediately because there is no point replaying a broken request.

Screenshot API vs. Selenium and Playwright on the JVM

Selenium and Playwright shine when you need to log in, click through wizards, or run E2E test suites. If your Kotlin code only needs to convert a URL into bytes, driving a full browser is overhead you can avoid.

Self-hosted (Selenium / Playwright)ScreenshotRun API
SetupDownload Chrome + matching driver, install system fontsAdd OkHttp or ktor-client to Gradle
Binary sizeChromium adds 300+ MB to your deploy artifactNo extra binaries in your JAR
Memory footprintEach Chrome process claims 200-500 MB on top of your JVMRendering happens off-box. Your JVM memory stays unchanged
CoroutinesSelenium blocks the calling thread; Playwright has limited asyncktor-client suspends natively, no thread wasted
AndroidCannot run headless Chrome on AndroidWorks on any platform with HTTP support
Cookie consent popupsYou maintain per-site dismiss logic that breaks on redesignsOne query flag: block_cookies=true
DockerMulti-stage build, Chrome layer adds 1+ GB, slow CIStandard JVM image, nothing extra
PricingCompute costs grow with each concurrent Chrome processFree for 200 captures/month, usage-based after

Keep Selenium or Playwright for workflows that navigate multiple pages or submit forms. For everything else (dashboard exports, link previews, scheduled captures), one OkHttp call or ktor-client suspend function does the job without a browser process.

On the JVM, common patterns include nightly dashboard exports in Spring Boot, link preview thumbnails in Ktor APIs, and scheduled monitoring jobs that archive pages as evidence.

FAQ#

OkHttp works well for synchronous code and Android projects. If you already use Kotlin coroutines, ktor-client is the better fit because it suspends instead of blocking a thread. Both send the same GET request to the API.

Yes. Since the API is a standard REST endpoint, any HTTP client that runs on Android (OkHttp, ktor-client, Retrofit) can call it. No headless browser is needed on the device.

Yes. Launch a coroutine per URL with async and collect the results with awaitAll. The API processes each request independently on our servers, so a batch of URLs finishes in roughly the time of a single capture.

Create a server-side proxy endpoint that adds the Authorization header before forwarding the request to ScreenshotRun. The frontend calls your backend route and never sees the key.

The API returns HTTP 429 with a Retry-After header. Your code should read that header and pause before retrying. With ktor-client, use kotlinx.coroutines.delay to suspend the coroutine without blocking the thread.

The free plan includes 200 captures per month with access to every parameter. No credit card required. Paid plans are available if you need higher volume.

Written by

Vitalii Holben

Co-founder and CEO of ScreenshotRun. Full-stack developer building screenshot API tools that help developers capture, automate, and monitor web pages at scale. Based in Europe, shipping code since 201...

Screenshot API in a few clicks

Free plan includes 200 screenshots every month. No credit card required.

Sign up → copy your key → make your first call