Skip to content
CastPDF

Generate PDFs in Go with net/http and a static binary

To generate a PDF in Go, marshal your HTML (or a saved template ID) and data with encoding/json, post it to CastPDF using net/http under a context timeout, and copy the response body into a file. No cgo, no Chrome binary, nothing beyond the standard library. It is the same PDF generation API every language uses.

  • Updated October 2026
  • 100 free PDFs a month

PDF options in Go

Go developers like single static binaries, and that preference shapes the PDF decision. A pure Go library keeps the binary self-contained but makes you lay out pages in code. Anything that renders HTML well needs a browser engine somewhere, and shipping one breaks the tidy FROM scratch image. These are the usual routes.

Ways to produce a PDF from Go
OptionHow it worksWorth knowing
gofpdf and its forksPure Go: you place cells, lines and images with method callsSmall and dependency free; the original gofpdf repository is archived and community forks carry it on; no HTML or CSS layout
chromedpDrives a Chrome or Chromium process over the DevTools protocol and calls its print to PDF commandFull modern CSS; you ship and update a browser next to your binary and manage its processes and memory
wkhtmltopdf wrappersGo packages that run the wkhtmltopdf executable for youSimple to call; the wkhtmltopdf repository was archived in January 2023 and its WebKit engine predates flexbox and grid
CastPDFOne HTTPS request from net/http; Chromium with print features runs on our sideA network round trip per document and a monthly plan beyond the free allowance

For a fixed layout such as a label, a pure Go library is hard to beat. The trade-off shows up with documents like the monthly customer statement on this page: a variable number of lines, a running balance, a header row that should repeat on each page and a total that must stay with the table. Writing that pagination by hand in a drawing library takes days, and running Chrome beside every service takes ongoing care. An HTTP call keeps your binary static and the layout in HTML.

Your first statement PDF in Go

Copy a test key from the dashboard (it begins with cpdf_test_) and export it as CASTPDF_API_KEY. Renders with a test key are free, unlimited and watermarked, so experiment as much as you like. The program below uses only the standard library; save it as main.go in a fresh module and run go run ..

main.go (Go 1.21+)
package main

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

const statementHTML = `
<h1>Statement for {{ customer.name }}</h1>
<p>{{ period.label }}. Opening balance {{ period.opening | money: "EUR", "de-DE" }}</p>
<table>
  <thead><tr><th>Date</th><th>Reference</th><th>Amount</th></tr></thead>
  <tbody>
  {% for line in lines %}
    <tr><td>{{ line.date | format_date: "short", "UTC", "de-DE" }}</td><td>{{ line.ref }}</td><td>{{ line.amount | money: "EUR", "de-DE" }}</td></tr>
  {% endfor %}
  </tbody>
</table>`

type apiError struct {
	Error struct {
		Code    string `json:"code"`
		Message string `json:"message"`
	} `json:"error"`
}

func main() {
	payload := map[string]any{
		"html": statementHTML,
		"data": map[string]any{
			"customer": map[string]any{"name": "Harbour Lane Bakery"},
			"period":   map[string]any{"label": "September 2026", "opening": 2140.80},
			"lines": []map[string]any{
				{"date": "2026-09-04", "ref": "Invoice INV-7710", "amount": 1240},
				{"date": "2026-09-18", "ref": "Payment received", "amount": -1240},
				{"date": "2026-09-27", "ref": "Invoice INV-7795", "amount": 986.50},
			},
		},
		"mode":     "print",
		"filename": "statement-2026-09",
	}
	body, err := json.Marshal(payload)
	if err != nil {
		log.Fatal(err)
	}

	ctx, cancel := context.WithTimeout(context.Background(), 75*time.Second)
	defer cancel()

	req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.castpdf.com/v1/pdf", bytes.NewReader(body))
	if err != nil {
		log.Fatal(err)
	}
	req.Header.Set("Authorization", "Bearer "+os.Getenv("CASTPDF_API_KEY"))
	req.Header.Set("Content-Type", "application/json")

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

	if res.StatusCode != http.StatusOK {
		var e apiError
		_ = json.NewDecoder(res.Body).Decode(&e)
		log.Fatalf("%d %s: %s", res.StatusCode, e.Error.Code, e.Error.Message)
	}

	out, err := os.Create("statement.pdf")
	if err != nil {
		log.Fatal(err)
	}
	defer out.Close()
	if _, err := io.Copy(out, res.Body); err != nil {
		log.Fatal(err)
	}
	fmt.Println("saved statement.pdf,", res.Header.Get("X-Pages"), "page(s)")
}

The context gives the whole exchange a 75 second budget, which matters because http.DefaultClient has no timeout of its own. io.Copy streams the PDF straight from the socket to disk, so a large statement never sits in memory as one byte slice. Since the body includes data, CastPDF treats the HTML as a Liquid template: the money filter prints German euro formatting such as 1.240,00 €, and "mode": "print" repeats the thead on every page once the statement grows past one.

Keep html/template away from the Liquid tags

Go's text/template and html/template also use {{ }}, so parsing this markup with them fails or eats the Liquid tags. Keep Liquid HTML as a plain string (or a file loaded with embed), or call Delims("[[", "]]") on your Go template if you need both in one file.

Fill a saved template from Go structs

Raw HTML in a Go constant works, but every layout tweak then means a deploy. Move the design into a saved template instead. You edit its HTML and CSS in the dashboard with a live preview, and someone from accounts can update the sample figures in Simple mode without touching code. The service then sends the template ID plus typed data, and here it asks for a signed link rather than the PDF bytes, ready to drop into a customer portal.

statement_link.go
package main

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

type statementLine struct {
	Date   string  `json:"date"`
	Ref    string  `json:"ref"`
	Amount float64 `json:"amount"`
}

type statementRequest struct {
	TemplateID string         `json:"template_id"`
	Data       map[string]any `json:"data"`
	Filename   string         `json:"filename"`
	Response   string         `json:"response"`
}

type pdfLink struct {
	ID        string    `json:"id"`
	Pages     int       `json:"pages"`
	Bytes     int64     `json:"bytes"`
	URL       string    `json:"url"`
	ExpiresAt time.Time `json:"expires_at"`
}

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

func statementLink(ctx context.Context, customerID, customerName, period string, lines []statementLine) (pdfLink, error) {
	key := fmt.Sprintf("statement-%s-%s", customerID, period)
	body, err := json.Marshal(statementRequest{
		TemplateID: os.Getenv("STATEMENT_TEMPLATE_ID"),
		Data: map[string]any{
			"customer": map[string]any{"id": customerID, "name": customerName},
			"period":   map[string]any{"label": period},
			"lines":    lines,
		},
		Filename: key,
		Response: "url",
	})
	if err != nil {
		return pdfLink{}, err
	}

	ctx, cancel := context.WithTimeout(ctx, 75*time.Second)
	defer cancel()
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.castpdf.com/v1/pdf", bytes.NewReader(body))
	if err != nil {
		return pdfLink{}, err
	}
	req.Header.Set("Authorization", "Bearer "+os.Getenv("CASTPDF_API_KEY"))
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Idempotency-Key", key)

	res, err := client.Do(req)
	if err != nil {
		return pdfLink{}, err
	}
	defer res.Body.Close()
	if res.StatusCode != http.StatusOK {
		return pdfLink{}, fmt.Errorf("castpdf: unexpected status %s", res.Status)
	}

	var link pdfLink
	err = json.NewDecoder(res.Body).Decode(&link)
	return link, err
}

func main() {
	lines := []statementLine{
		{Date: "2026-09-04", Ref: "Invoice INV-7710", Amount: 1240},
		{Date: "2026-09-18", Ref: "Payment received", Amount: -1240},
		{Date: "2026-09-27", Ref: "Invoice INV-7795", Amount: 986.5},
	}
	link, err := statementLink(context.Background(), "C-2291", "Harbour Lane Bakery", "2026-09", lines)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(link.URL, link.Pages, "pages, expires", link.ExpiresAt.Format(time.RFC1123))
}

Every field carries a json tag. Without tags, encoding/json would send TemplateID as the key, and the API rejects unknown fields with invalid_request. The Idempotency-Key combines the customer and the period, so a cron job that fires twice, or a retry after a timeout, returns the statement that already exists instead of rendering and billing it again. The data map replaces the template's sample data as a whole, so include every field the template reads.

expires_at decodes straight into a time.Time, because it is an ISO 8601 timestamp. Until that moment the url downloads the file without any key. Keep the id as well: calling GET /v1/pdf/:id later issues a fresh link for the same stored document. Field details for both endpoints are in the API reference.

Error handling and retries in Go

Errors come back as JSON with an error object holding a stable code, a human message and a docs_url. In Go that maps neatly onto a custom error type that callers can inspect with errors.As. Retry only on 409 (a request with the same key is still running), 429 (honour Retry-After), 503 (back off) and transport errors. Note that the request body has to be rebuilt for every attempt: a bytes.Reader that was read once is empty the second time.

castpdf/render.go
package castpdf

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

// Error is a non-retryable answer from the API.
type Error struct {
	Status  int
	Code    string
	Message string
}

func (e *Error) Error() string {
	return fmt.Sprintf("castpdf: %d %s: %s", e.Status, e.Code, e.Message)
}

// Render posts payload and retries 409, 429, 503 and transport errors with the same idempotency key.
func Render(ctx context.Context, client *http.Client, payload []byte, idempotencyKey string) ([]byte, error) {
	const maxAttempts = 5
	for attempt := 1; ; attempt++ {
		res, body, err := post(ctx, client, payload, idempotencyKey)
		if err == nil && res.StatusCode == http.StatusOK {
			return body, nil
		}

		wait := time.Duration(1<<attempt) * time.Second
		if err == nil {
			apiErr := decodeError(res.StatusCode, body)
			if !retryable(res.StatusCode) {
				return nil, apiErr
			}
			if s, convErr := strconv.Atoi(res.Header.Get("Retry-After")); convErr == nil {
				wait = time.Duration(s) * time.Second
			}
			err = apiErr
		}
		if attempt == maxAttempts || ctx.Err() != nil {
			return nil, err
		}

		select {
		case <-ctx.Done():
			return nil, ctx.Err()
		case <-time.After(wait):
		}
	}
}

func post(ctx context.Context, client *http.Client, payload []byte, idempotencyKey string) (*http.Response, []byte, error) {
	reqCtx, cancel := context.WithTimeout(ctx, 75*time.Second)
	defer cancel()

	req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, "https://api.castpdf.com/v1/pdf", bytes.NewReader(payload))
	if err != nil {
		return nil, nil, err
	}
	req.Header.Set("Authorization", "Bearer "+os.Getenv("CASTPDF_API_KEY"))
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Idempotency-Key", idempotencyKey)

	res, err := client.Do(req)
	if err != nil {
		return nil, nil, err
	}
	defer res.Body.Close()
	body, err := io.ReadAll(res.Body)
	return res, body, err
}

func retryable(status int) bool {
	return status == http.StatusConflict || status == http.StatusTooManyRequests || status == http.StatusServiceUnavailable
}

func decodeError(status int, body []byte) *Error {
	var envelope struct {
		Error struct {
			Code    string `json:"code"`
			Message string `json:"message"`
		} `json:"error"`
	}
	_ = json.Unmarshal(body, &envelope)
	return &Error{Status: status, Code: envelope.Error.Code, Message: envelope.Error.Message}
}

Each attempt gets a fresh bytes.NewReader inside post, which avoids the classic http: ContentLength=... with Body length 0 error on a retry. The outer ctx caps the total time across attempts, and the inner one caps a single request. A caller can then write var apiErr *castpdf.Error and errors.As(err, &apiErr) to branch on apiErr.Code, for example to mark a statement as failed when the code is template_render_error.

Production checklist for Go services

  • Give every request a deadline of at least 60 seconds, through context.WithTimeout or http.Client.Timeout. A render may take up to 30 seconds, and http.DefaultClient on its own waits forever.
  • Create one http.Client and share it. It is safe for concurrent use and pools connections, so goroutines reuse TLS sessions instead of opening a new one per PDF.
  • Always close res.Body, even on errors, or connections leak and the pool slowly runs dry.
  • Bound your concurrency. Renders running at the same time are capped per plan (Free 1, Starter 2, Growth 3, Pro 4 and Scale 4), and one more returns 429 with Retry-After: 1. A buffered channel used as a semaphore keeps a fan-out of goroutines inside that limit.
  • In a handler, derive the API context from r.Context() so the call stops when the client goes away. Because the idempotency key is stable, a later request picks up the finished document instead of rendering again.
  • Images built FROM scratch or distroless need CA certificates for TLS. Copy /etc/ssl/certs/ca-certificates.crt from a builder stage, or the first call fails with an x509 error.
  • Keep documents under 50 pages and 40 MB. Read the key from the environment or your secrets store, and switch from cpdf_test_ to cpdf_live_ at launch without changing code.

Common errors from Go code

Errors Go developers hit first, and the fix
Symptom or codeLikely causeFix
x509: certificate signed by unknown authorityA scratch or minimal image without a CA bundleCopy the CA certificates into the final image
context deadline exceededThe deadline was shorter than the render, often a 30 second default copied from elsewhereUse 60 seconds or more and retry with the same idempotency key
invalid_request (400)A struct field without a json tag, so the key went out as TemplateID or FilenameAdd json:"template_id" style tags to every field
http: ContentLength=... with Body length 0A retry reused a reader that was already consumedCreate a new bytes.NewReader(payload) for each attempt
template_render_error (422)Go template syntax like {{ .Customer.Name }} inside a Liquid templateUse Liquid paths such as {{ customer.name }}; check details.line and details.column
rate_limited (429)Too many requests or renders at once (test keys allow 20 a minute)Wait Retry-After seconds and lower your goroutine fan-out

The quickstart covers keys, starter templates and the editor. Running JVM services as well? The Java page makes the same calls with java.net.http.HttpClient. For a scripting take on the same flow, see the Node.js page.

FAQ

Common questions

Can I generate PDFs in Go without cgo or a browser?

Yes. The API call uses only net/http and encoding/json, so CGO_ENABLED=0 builds and scratch images work. The browser runs on CastPDF servers, not inside your container.

Is there a Go module or client library for CastPDF?

No, and the standard library covers everything. A typed request struct, json.Marshal and one POST are the whole integration. The render.go file on this page is a complete retrying client you can copy into your project.

How do I stream the PDF to an http.ResponseWriter in Go?

Set Content-Type to application/pdf and a Content-Disposition header on the writer, then io.Copy the API response body into it. Nothing is buffered in memory. Derive the API request context from r.Context() so it stops if the visitor leaves.

Can I render many statements in parallel with goroutines?

Yes, within your plan limits. Each plan allows a fixed number of renders at the same time, and extra requests get a 429 with a short Retry-After. Use a semaphore sized to that limit and the retry helper on this page.

Should I use chromedp or a PDF API?

chromedp is a good choice if you already run Chrome and want full control over it. An API suits teams that prefer a static binary and no browser fleet to patch, monitor and restart. Both render HTML with Chromium.

Does a 30 page statement cost more than a 1 page statement?

No. CastPDF counts documents, not pages, so a long statement counts as one PDF. Test key renders are free and never count at all.

Make your first PDF in 5 minutes

Pick a template, add your details and download your PDF. You get 100 free PDFs every month, and you don’t need a card.