PDFPipe

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:

elixir
# 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
end

Phoenix 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:

elixir
# 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, :download

Store 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:

elixir
# 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 →