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:
| Parameter | Type | Default | Effect |
|---|---|---|---|
url | String | required | Target page to render |
format | String | png | Choose between png, jpeg, webp, avif, or pdf output |
width | Int | 1280 | Rendered page width in pixels |
height | Int | 720 | Rendered page height in pixels |
full_page | Boolean | false | Capture below the fold by scrolling to the bottom and stitching |
device | String | desktop | Simulates a desktop, phone, or tablet screen size |
retina | Boolean | false | Produces a Retina-quality image at twice the resolution |
dark_mode | Boolean | false | Switch the page to its dark theme if one exists |
selector | String | CSS selector to clip to one element | |
delay | Int | 0 | Extra wait time (ms) for JS-heavy pages to settle |
block_cookies | Boolean | false | Strip cookie consent dialogs |
block_ads | Boolean | false | Remove ads before the page is captured |
block_chats | Boolean | false | Remove support chat overlays |
css | String | Your own CSS rules, loaded before the screenshot runs | |
js | String | Custom 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 | |
|---|---|---|
| Setup | Download Chrome + matching driver, install system fonts | Add OkHttp or ktor-client to Gradle |
| Binary size | Chromium adds 300+ MB to your deploy artifact | No extra binaries in your JAR |
| Memory footprint | Each Chrome process claims 200-500 MB on top of your JVM | Rendering happens off-box. Your JVM memory stays unchanged |
| Coroutines | Selenium blocks the calling thread; Playwright has limited async | ktor-client suspends natively, no thread wasted |
| Android | Cannot run headless Chrome on Android | Works on any platform with HTTP support |
| Cookie consent popups | You maintain per-site dismiss logic that breaks on redesigns | One query flag: block_cookies=true |
| Docker | Multi-stage build, Chrome layer adds 1+ GB, slow CI | Standard JVM image, nothing extra |
| Pricing | Compute costs grow with each concurrent Chrome process | Free 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.