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 →