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.
| Option | How it works | Worth knowing |
|---|---|---|
| gofpdf and its forks | Pure Go: you place cells, lines and images with method calls | Small and dependency free; the original gofpdf repository is archived and community forks carry it on; no HTML or CSS layout |
| chromedp | Drives a Chrome or Chromium process over the DevTools protocol and calls its print to PDF command | Full modern CSS; you ship and update a browser next to your binary and manage its processes and memory |
| wkhtmltopdf wrappers | Go packages that run the wkhtmltopdf executable for you | Simple to call; the wkhtmltopdf repository was archived in January 2023 and its WebKit engine predates flexbox and grid |
| CastPDF | One HTTPS request from net/http; Chromium with print features runs on our side | A 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 ..
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.
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.
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.WithTimeoutorhttp.Client.Timeout. A render may take up to 30 seconds, andhttp.DefaultClienton its own waits forever. - Create one
http.Clientand 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
429withRetry-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 scratchor distroless need CA certificates for TLS. Copy/etc/ssl/certs/ca-certificates.crtfrom 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_tocpdf_live_at launch without changing code.
Common errors from Go code
| Symptom or code | Likely cause | Fix |
|---|---|---|
x509: certificate signed by unknown authority | A scratch or minimal image without a CA bundle | Copy the CA certificates into the final image |
context deadline exceeded | The deadline was shorter than the render, often a 30 second default copied from elsewhere | Use 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 Filename | Add json:"template_id" style tags to every field |
http: ContentLength=... with Body length 0 | A retry reused a reader that was already consumed | Create a new bytes.NewReader(payload) for each attempt |
template_render_error (422) | Go template syntax like {{ .Customer.Name }} inside a Liquid template | Use 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.