HTML to PDF
in Go.

Go's appeal is one static binary with no runtime. Embedding Chrome to make a PDF throws that away. An HTTP call does not.

The call

Post your markup with an API key. The PDF comes back in the same response, base64 encoded by default. Nothing is written to disk on our side.

main.go
package main

import (
	"bytes"
	"encoding/base64"
	"encoding/json"
	"log"
	"net/http"
	"os"
)

type result struct {
	PDF              string `json:"pdf"`
	CreditsRemaining int    `json:"creditsRemaining"`
}

func main() {
	body, _ := json.Marshal(map[string]any{
		"html":    "<h1>Invoice INV-1042</h1><p>Total: $1,645.40</p>",
		"options": map[string]any{"format": "A4", "printBackground": true},
	})

	req, _ := http.NewRequest("POST", "https://api.html4pdf.com/generate-pdf", bytes.NewReader(body))
	req.Header.Set("Authorization", "Bearer "+os.Getenv("HTML4PDF_KEY"))
	req.Header.Set("Content-Type", "application/json")

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		log.Fatal(err)
	}
	defer res.Body.Close()

	var out result
	if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
		log.Fatal(err)
	}

	pdf, _ := base64.StdEncoding.DecodeString(out.PDF)
	if err := os.WriteFile("invoice.pdf", pdf, 0o644); err != nil {
		log.Fatal(err)
	}
}

Serving it from a handler

With a binary response you can copy the body straight to the response writer, so nothing is buffered twice.

handler.go
func invoicePDF(w http.ResponseWriter, r *http.Request) {
	body, _ := json.Marshal(map[string]any{
		"html":           renderInvoice(r.PathValue("id")),
		"responseFormat": "binary",
	})

	req, _ := http.NewRequest("POST", "https://api.html4pdf.com/generate-pdf", bytes.NewReader(body))
	req.Header.Set("Authorization", "Bearer "+os.Getenv("HTML4PDF_KEY"))
	req.Header.Set("Content-Type", "application/json")

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

	w.Header().Set("Content-Type", "application/pdf")
	io.Copy(w, res.Body)
}

What comes back

The default response is JSON with the document base64 encoded, the credits you have left, and how long the render took. Send responseFormat as binary instead and the body is the PDF itself, with a Content-Type of application/pdf.

200 OK
{
  "success": true,
  "pdf": "JVBERi0xLjQKJeLjz9MKMyAwIG9...",
  "creditsRemaining": 9,
  "metadata": { "size": 42813, "processingTime": 2841 }
}

A 402 means you are out of credits, a 401 means the key is wrong, and a 422 means the markup could not be rendered. Everything else is worth retrying once.

The usual Go libraries

Worth knowing before you add a dependency, because for a lot of documents one of these is the right answer and costs nothing.

LibraryWhat it isThe trade-off
chromedpDrives Chrome over the DevTools protocol from Go.Exact rendering and full control. Your container now contains Chrome, and your deployment inherits its memory profile.
gofpdfA port of FPDF that draws the document in code.No dependencies, no HTML. The original repository is archived, so maintenance is community forks.
MarotoA grid-based layout wrapper over a drawing library.Pleasant for reports with repeating rows. Still code, not markup.

Go: common questions

How do I convert HTML to PDF in Go without Chrome?

Post the HTML to a service that runs Chrome and write the bytes it returns. Your binary stays static, with no cgo and no browser in the image.

Does it need cgo?

No. It is net/http and encoding/json from the standard library.

How long does a request take?

Three to five seconds for a typical document. Set a client timeout of 60 seconds to be safe.

Can I stream the PDF to the client?

Yes. Ask for responseFormat binary and io.Copy the response body into your ResponseWriter.