Back to Blog
Guide February 26, 2026 · 4 min read

How to Take Screenshots and Generate PDFs in Scala

Scala inherits the JVM's headless browser problem — Selenium requires ChromeDriver, scala-scraper only parses HTML, and Puppeteer bridges add a Node.js runtime. Here's the cleaner path: one HTTP call with sttp, binary response. Works in Play, Akka HTTP, http4s, and ZIO.

Scala inherits the JVM's headless browser problem. Options typically include Selenium WebDriver (requires ChromeDriver), scala-scraper (HTML parsing, not rendering), or bridging to a Node.js Puppeteer process. None of these are easy to manage in an Akka or ZIO application.

Here's the cleaner path: one HTTP call, binary response. Works in any Scala project — Play, Akka HTTP, http4s, or plain sbt run.

Using sttp (recommended)

sttp is the standard choice for synchronous and async HTTP in Scala:

// build.sbt
libraryDependencies ++= Seq(
  "com.softwaremill.sttp.client4" %% "core" % "4.0.0-RC1",
  "com.softwaremill.sttp.client4" %% "circe" % "4.0.0-RC1",
  "io.circe" %% "circe-generic" % "0.14.7"
)
import sttp.client4._
import sttp.client4.circe._
import io.circe.generic.auto._

object PageBolt {
  private val apiKey = sys.env("PAGEBOLT_API_KEY")
  private val baseUrl = "https://pagebolt.dev/api/v1"
  private val backend = DefaultSyncBackend()

  case class ScreenshotRequest(url: String, fullPage: Boolean = true, blockBanners: Boolean = true)
  case class PdfRequest(url: Option[String] = None, html: Option[String] = None, blockBanners: Boolean = true)

  def screenshot(url: String): Array[Byte] =
    basicRequest
      .post(uri"$baseUrl/screenshot")
      .header("x-api-key", apiKey)
      .body(ScreenshotRequest(url))
      .response(asByteArrayAlways)
      .send(backend)
      .body

  def pdfFromUrl(url: String): Array[Byte] =
    basicRequest
      .post(uri"$baseUrl/pdf")
      .header("x-api-key", apiKey)
      .body(PdfRequest(url = Some(url)))
      .response(asByteArrayAlways)
      .send(backend)
      .body

  def pdfFromHtml(html: String): Array[Byte] =
    basicRequest
      .post(uri"$baseUrl/pdf")
      .header("x-api-key", apiKey)
      .body(PdfRequest(html = Some(html)))
      .response(asByteArrayAlways)
      .send(backend)
      .body
}

Play Framework controller

import play.api.mvc._
import play.api.libs.ws._
import javax.inject._
import scala.concurrent.{ExecutionContext, Future}

@Singleton
class InvoiceController @Inject() (
  ws: WSClient,
  cc: ControllerComponents
)(implicit ec: ExecutionContext) extends AbstractController(cc) {

  private val apiKey = sys.env("PAGEBOLT_API_KEY")
  private val baseUrl = "https://pagebolt.dev/api/v1"

  def downloadPdf(id: Long) = Action.async {
    val html = renderInvoiceTemplate(id)

    ws.url(s"$baseUrl/pdf")
      .withHttpHeaders("x-api-key" -> apiKey, "Content-Type" -> "application/json")
      .post(s"""{"html":${ujson.Str(html)}}""")
      .map { response =>
        Ok(response.bodyAsBytes)
          .as("application/pdf")
          .withHeaders("Content-Disposition" -> s"""attachment; filename="invoice-$id.pdf"""")
      }
  }
}

Akka HTTP route

import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.model._
import akka.http.scaladsl.Http
import akka.stream.Materializer
import scala.concurrent.Future

class InvoiceRoutes(pagebolt: PageBoltAsync)(implicit mat: Materializer) {
  val routes =
    path("invoices" / LongNumber / "pdf") { id =>
      get {
        val html = renderInvoiceTemplate(id)
        onSuccess(pagebolt.pdfFromHtml(html)) { pdfBytes =>
          complete(
            HttpResponse(
              entity = HttpEntity(ContentType(MediaTypes.`application/pdf`), pdfBytes),
              headers = List(
                headers.`Content-Disposition`(
                  ContentDispositionTypes.attachment,
                  Map("filename" -> s"invoice-$id.pdf")
                )
              )
            )
          )
        }
      }
    }
}

ZIO-based async client

import zio._
import zio.http._

object PageBoltZIO {
  val screenshot: ZIO[Client, Throwable, Chunk[Byte]] =
    for {
      apiKey <- System.env("PAGEBOLT_API_KEY").someOrFail(new Exception("PAGEBOLT_API_KEY not set"))
      response <- Client.batched(
        Request.post(
          url = URL.decode("https://pagebolt.dev/api/v1/screenshot").toOption.get,
          body = Body.fromString("""{"url":"https://example.com","fullPage":true}""")
        ).addHeader("x-api-key", apiKey)
         .addHeader(Header.ContentType(MediaType.application.json))
      )
      bytes <- response.body.asChunk
    } yield bytes
}

No ChromeDriver, no Selenium, no browser process to supervise. sttp's synchronous backend works with any ExecutionContext and is straightforward to test — swap DefaultSyncBackend() for RecordingSttpBackend in tests.

Get Started Free

100 requests/month, no credit card

Screenshots, PDFs, and video recordings from your Scala app — no Selenium, no ChromeDriver, works in Play, Akka HTTP, http4s, and ZIO.

Get Your Free API Key →