PDFPipe

Guide

Generate a PDF from HTML in Go

You have an HTTP handler, some HTML (an invoice, a report, a contract), and you need to return a PDF. The usual answers in Go are driving headless Chrome with chromedp or shelling out to a wkhtmltopdf binary. This guide shows the lighter path: one net/http request to a rendering API, streamed straight back to the client.

The minimal handler

The PDFPipe endpoint is POST https://api.pdfpipe.xyz/v1/pdf. Send a JSON body with your HTML and options, attach a bearer token, and you get back raw application/pdf bytes. Here is a complete handler using only the standard library.

go
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"time"
)

var client = &http.Client{Timeout: 30 * time.Second}

func invoiceHandler(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	html := fmt.Sprintf(`
		<html>
			<body style="font-family: sans-serif; padding: 40px">
				<h1>Invoice #%s</h1>
				<p>Amount due: $420.00</p>
			</body>
		</html>`, id)

	payload, _ := json.Marshal(map[string]any{
		"html":    html,
		"options": map[string]string{"format": "A4"},
	})

	req, _ := http.NewRequestWithContext(r.Context(),
		http.MethodPost, "https://api.pdfpipe.xyz/v1/pdf", bytes.NewReader(payload))
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", "Bearer "+os.Getenv("PDFPIPE_KEY"))

	resp, err := client.Do(req)
	if err != nil {
		http.Error(w, "pdf render failed", http.StatusBadGateway)
		return
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		detail, _ := io.ReadAll(resp.Body)
		http.Error(w, "pdf render failed: "+string(detail), http.StatusBadGateway)
		return
	}

	w.Header().Set("Content-Type", "application/pdf")
	w.Header().Set("Content-Disposition",
		fmt.Sprintf("inline; filename=%q", "invoice-"+id+".pdf"))
	io.Copy(w, resp.Body) // stream, no buffering
}

func main() {
	http.HandleFunc("GET /invoice/{id}", invoiceHandler)
	http.ListenAndServe(":8080", nil)
}

That is the whole feature. No browser binary in the container, no CGO, no zombie Chrome processes. io.Copy streams the PDF through your handler without ever holding the full document in memory, and passing r.Context() means a client disconnect cancels the upstream render too.

Why not chromedp or wkhtmltopdf?

Both are fine tools with real costs in a service. chromedp needs a Chrome or Chromium install in your image and burns hundreds of MB of memory per render. wkhtmltopdf is a 2015-era WebKit frozen in time: flexbox and modern CSS silently break, and the project is archived.

Concernchromedp / wkhtmltopdfnet/http to PDFPipe
Image sizeChrome adds ~280 MB; wkhtmltopdf needs Qt libsZero, it is one HTTP call
Static binary deploysBroken, you now ship a browser beside your binaryPreserved, scratch images keep working
Modern CSSwkhtmltopdf fails on flexbox and gridCurrent engine, renders what your browser renders
Memory per renderA browser tab, often 200+ MBA streamed response body

Timeouts and retries

Treat the render like any upstream dependency: bound it with a context deadline and retry once on transient failure. Renders are idempotent, so a retry is always safe.

go
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, url, body)

// Idempotent render: one retry on 5xx or transport error is safe.
resp, err := client.Do(req)
if err != nil || resp.StatusCode >= 500 {
	resp, err = client.Do(req)
}

Keep the API key in an environment variable. The options object also accepts other page sizes (for example Letter), margins, and landscape orientation, documented in full on the docs page.

Paste your own HTML into the live playground on the home page to see the PDF render instantly, then read the docs for every option.

See pricing →