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

How to Take Screenshots and Generate PDFs in Elixir

Elixir has no native headless browser. The usual options — wkhtmltopdf binary, Node.js Puppeteer subprocess, or chrome_remote_interface — are all awkward to supervise in an OTP application. Here's the simpler path: one HTTP call with Req or HTTPoison, binary response.

Elixir has no native headless browser. Teams that need screenshots or PDFs typically shell out to a wkhtmltopdf binary, run a Node.js Puppeteer subprocess, or use chrome_remote_interface — a Chromium binding that requires a running Chrome instance. None of these are easy to supervise in an OTP application.

Here's the simpler path: one HTTP call, binary response. Works in any Mix project or Phoenix endpoint.

Using Req (recommended)

Req is the modern Elixir HTTP client. Add it to mix.exs:

defp deps do
  [
    {:req, "~> 0.5"}
  ]
end
defmodule PageBolt do
  @base_url "https://pagebolt.dev/api/v1"

  defp api_key, do: System.fetch_env!("PAGEBOLT_API_KEY")

  def screenshot(url) do
    Req.post!(
      "#{@base_url}/screenshot",
      headers: [{"x-api-key", api_key()}],
      json: %{url: url, fullPage: true, blockBanners: true}
    ).body
  end

  def pdf_from_url(url) do
    Req.post!(
      "#{@base_url}/pdf",
      headers: [{"x-api-key", api_key()}],
      json: %{url: url, blockBanners: true}
    ).body
  end

  def pdf_from_html(html) do
    Req.post!(
      "#{@base_url}/pdf",
      headers: [{"x-api-key", api_key()}],
      json: %{html: html}
    ).body
  end
end

Req handles JSON encoding automatically when you pass json: — no manual Jason.encode! needed.

Using HTTPoison

If your project already uses HTTPoison:

defmodule PageBolt do
  @base_url "https://pagebolt.dev/api/v1"

  defp headers do
    [
      {"x-api-key", System.fetch_env!("PAGEBOLT_API_KEY")},
      {"Content-Type", "application/json"}
    ]
  end

  def screenshot(url) do
    body = Jason.encode!(%{url: url, fullPage: true, blockBanners: true})
    {:ok, %{body: data}} = HTTPoison.post("#{@base_url}/screenshot", body, headers())
    data
  end

  def pdf_from_html(html) do
    body = Jason.encode!(%{html: html})
    {:ok, %{body: data}} = HTTPoison.post("#{@base_url}/pdf", body, headers())
    data
  end
end

Phoenix controller — PDF download

defmodule MyAppWeb.InvoiceController do
  use MyAppWeb, :controller

  def download_pdf(conn, %{"id" => id}) do
    invoice = MyApp.Invoices.get!(id)
    html = Phoenix.View.render_to_string(MyAppWeb.InvoiceView, "show.html", invoice: invoice)

    pdf = PageBolt.pdf_from_html(html)

    conn
    |> put_resp_content_type("application/pdf")
    |> put_resp_header("content-disposition", ~s(attachment; filename="invoice-#{id}.pdf"))
    |> send_resp(200, pdf)
  end
end

Phoenix LiveView — on-demand screenshot

defmodule MyAppWeb.DashboardLive do
  use MyAppWeb, :live_view

  def handle_event("export_screenshot", _params, socket) do
    url = MyAppWeb.Router.Helpers.dashboard_url(socket, :show)
    image = PageBolt.screenshot(url)

    # Save to object storage or return as download
    {:ok, path} = MyApp.Storage.upload(image, "dashboard-#{Date.utc_today()}.png")

    {:noreply, assign(socket, :screenshot_url, path)}
  end
end

Supervised GenServer wrapper

For production use, wrap the client in a GenServer to manage the API key and add retry logic:

defmodule MyApp.PageBolt do
  use GenServer

  def start_link(_opts) do
    GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
  end

  def screenshot(url), do: GenServer.call(__MODULE__, {:screenshot, url})
  def pdf_from_html(html), do: GenServer.call(__MODULE__, {:pdf_html, html})

  @impl true
  def init(_) do
    {:ok, %{api_key: System.fetch_env!("PAGEBOLT_API_KEY")}}
  end

  @impl true
  def handle_call({:screenshot, url}, _from, %{api_key: key} = state) do
    result = Req.post!(
      "https://pagebolt.dev/api/v1/screenshot",
      headers: [{"x-api-key", key}],
      json: %{url: url, fullPage: true, blockBanners: true}
    ).body
    {:reply, result, state}
  end

  @impl true
  def handle_call({:pdf_html, html}, _from, %{api_key: key} = state) do
    result = Req.post!(
      "https://pagebolt.dev/api/v1/pdf",
      headers: [{"x-api-key", key}],
      json: %{html: html}
    ).body
    {:reply, result, state}
  end
end

Add to your supervision tree:

# application.ex
children = [
  MyApp.Repo,
  MyAppWeb.Endpoint,
  MyApp.PageBolt   # <- add this
]

No wkhtmltopdf binary, no Node.js subprocess, no Chrome process to supervise. Req ships as a pure-Elixir dependency — one entry in mix.exs, no system packages.

Get Started Free

100 requests/month, no credit card

Screenshots, PDFs, and video recordings from your Elixir app — no wkhtmltopdf, no Node.js subprocess, no Chrome process to supervise in OTP.

Get Your Free API Key →