Guide
HTML to PDF in Elixir
Elixir has no first-party PDF library. The options on Hex are mostly thin wrappers around external binaries (wkhtmltopdf, WeasyPrint) that complicate deployments and behave inconsistently across Linux distributions. PDFPipe is a single HTTP call using the Req library that is already in most Elixir projects.
Req HTTP call
Add :req to your mix.exs dependencies, then call PDFPipe with your HTML string. The response body is raw PDF bytes on success:
# mix.exs — add :req to your deps
# {:req, "~> 0.5"}
defmodule MyApp.PdfClient do
@api_url "https://api.pdfpipe.xyz/v1/pdf"
def render_html(html, opts \\ []) do
api_key = System.fetch_env!("PDFPIPE_API_KEY")
options = %{
format: Keyword.get(opts, :format, "A4"),
margin: %{top: "20mm", bottom: "20mm", left: "15mm", right: "15mm"}
}
case Req.post(@api_url,
json: %{html: html, options: options},
headers: [{"authorization", "Bearer #{api_key}"}],
receive_timeout: 30_000
) do
{:ok, %Req.Response{status: 200, body: pdf_bytes}} ->
{:ok, pdf_bytes}
{:ok, %Req.Response{status: status, body: body}} ->
detail = Map.get(body, "detail", "render failed")
{:error, "PDFPipe error #{status}: #{detail}"}
{:error, reason} ->
{:error, inspect(reason)}
end
end
endPhoenix controller returning PDF
Wire the client into a Phoenix controller action. Call the module above, set the correct content-type header, and send the binary directly. Phoenix handles chunking for you:
# lib/my_app_web/controllers/invoice_controller.ex
defmodule MyAppWeb.InvoiceController do
use MyAppWeb, :controller
alias MyApp.PdfClient
def download(conn, %{"id" => id}) do
html = render_invoice_html(id)
case PdfClient.render_html(html, format: "A4") do
{:ok, pdf_bytes} ->
conn
|> put_resp_content_type("application/pdf")
|> put_resp_header(
"content-disposition",
~s(attachment; filename="invoice-#{id}.pdf")
)
|> send_resp(200, pdf_bytes)
{:error, reason} ->
conn
|> put_status(:bad_gateway)
|> json(%{error: reason})
end
end
defp render_invoice_html(id) do
"""
<!DOCTYPE html>
<html>
<body style="font-family: system-ui; padding: 40px">
<h1>Invoice ##{id}</h1>
<p>Thank you for your order.</p>
</body>
</html>
"""
end
end
# router.ex
# get "/invoices/:id/pdf", InvoiceController, :downloadStore and return a download URL
Pass store: true to keep the document on the CDN instead of streaming the bytes. PDFPipe responds with headers containing the retrieval URL, document ID, and expiry. Useful for async jobs or when you want to redirect the user rather than stream from your server:
# Store the PDF and return a retrieval URL instead of streaming the bytes.
defmodule MyApp.PdfClient do
@api_url "https://api.pdfpipe.xyz/v1/pdf"
def store_html(html, filename, opts \\ []) do
api_key = System.fetch_env!("PDFPIPE_API_KEY")
options = %{
format: Keyword.get(opts, :format, "A4")
}
case Req.post(@api_url,
json: %{
html: html,
options: options,
store: true,
filename: filename
},
headers: [{"authorization", "Bearer #{api_key}"}],
receive_timeout: 30_000
) do
{:ok, %Req.Response{status: 200, headers: headers}} ->
doc_id = get_header(headers, "x-pdfpipe-document-id")
doc_url = get_header(headers, "x-pdfpipe-document-url")
expires = get_header(headers, "x-pdfpipe-document-expires")
{:ok, %{id: doc_id, url: doc_url, expires_at: expires}}
{:ok, %Req.Response{status: status, body: body}} ->
{:error, "PDFPipe error #{status}: #{inspect(body)}"}
{:error, reason} ->
{:error, inspect(reason)}
end
end
defp get_header(headers, key) do
case List.keyfind(headers, key, 0) do
{_key, value} -> value
nil -> nil
end
end
end
# Usage in a Phoenix controller or LiveView action:
# {:ok, %{url: url}} = PdfClient.store_html(html, "invoice-#{id}.pdf")
# redirect(conn, external: url)Getting an API key
The Hobby plan gives 500 free documents a month with no credit card required. Paid plans start at $19 and include a longer document archive, higher limits, and email support.
Full API reference on the docs page. Try it now in the live playground with no signup.
Get an API key →