\begin{article}
Calling the LaTeX API from Go
Build a production-ready LaTeX PDF client in Go using net/http — compile documents, handle errors, and stream PDFs with zero dependencies.

If you have ever tried to compile LaTeX inside a Go service, you know the drill: install TeX Live (2 GB+), call exec.Command("pdflatex", ...), parse stderr for errors, clean up temp files, and pray it works the same on every machine. It is a maintenance burden that has nothing to do with your actual product. The FormatEx LaTeX API Go client approach is simpler — POST your LaTeX source, get a PDF back, zero system dependencies.
This tutorial walks through building a fully typed, production-ready Go client for the FormatEx API using only the standard library. No third-party HTTP clients, no code generation. Just net/http, context, and clean Go patterns.
Why Not Just Shell Out to pdflatex?
Before writing any code, it is worth being explicit about what exec.Command("pdflatex", ...) actually costs you in production.
The hidden costs of local TeX Live:
- Full TeX Live installation is 4—8 GB depending on scheme
- Compilation is CPU- and memory-intensive — it spikes your containers
- Each compilation needs isolated temp directories to avoid race conditions
- Engine differences (
pdflatexvsxelatexvslualatex) require separate binaries and configs - TeX Live updates can silently break existing documents
- Docker image size balloons, slowing CI and cold starts
Compare that to an HTTP call that returns a PDF in under two seconds. The FormatEx API handles the TeX Live infrastructure, engine selection, and cleanup on its end. Your Go service stays lean.
Here is what the shell-out approach looks like versus the API approach:
| Concern | exec.Command("pdflatex") | FormatEx API |
|---|---|---|
| Docker image size | +4 GB | 0 MB added |
| Concurrency | File locking required | Stateless HTTP |
| Engine support | One binary per engine | Single endpoint, engine param |
| Error messages | Parse raw stderr | Structured JSON |
| Timeout control | OS process timeout | HTTP context deadline |
| Maintenance | TeX Live updates | API versioned |
If your use case is more than a weekend script, the API approach wins on every axis.
Structuring the Go Client
A good API client in Go has three things: a typed request/response struct, a client struct that holds config, and a method that does one thing well. We will build exactly that.
Start by defining the types:
package latexclient
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
const (
defaultBaseURL = "https://api.formatex.io/api/v1"
defaultTimeout = 120 * time.Second
)
// Engine represents a supported LaTeX compilation engine.
type Engine string
const (
EnginePDFLaTeX Engine = "pdflatex"
EngineXeLaTeX Engine = "xelatex"
EngineLuaLaTeX Engine = "lualatex"
EngineLatexmk Engine = "latexmk"
)
// CompileRequest is the payload sent to the compile endpoint.
type CompileRequest struct {
Source string `json:"source"`
Engine Engine `json:"engine,omitempty"`
}
// APIError represents an error response from the FormatEx API.
type APIError struct {
StatusCode int
Message string `json:"error"`
}
func (e *APIError) Error() string {
return fmt.Sprintf("formatex API error %d: %s", e.StatusCode, e.Message)
}
// Client is a FormatEx API client.
type Client struct {
baseURL string
apiKey string
httpClient *http.Client
}
// New creates a new Client with the given API key.
func New(apiKey string) *Client {
return &Client{
baseURL: defaultBaseURL,
apiKey: apiKey,
httpClient: &http.Client{
Timeout: defaultTimeout,
},
}
}
// WithTimeout returns a copy of the client with a custom HTTP timeout.
func (c *Client) WithTimeout(d time.Duration) *Client {
cp := *c
cp.httpClient = &http.Client{Timeout: d}
return &cp
}A few intentional decisions here:
APIErrorimplementserrorso callers can useerrors.Asto inspect status codesWithTimeoutreturns a copy rather than mutating — safe for concurrent use- The
Enginetype prevents typos at compile time instead of at runtime
Compiling a Document
Now the core method. The /api/v1/compile endpoint accepts JSON with a source field and an optional engine field, and returns the raw PDF bytes on success. Choosing the right engine matters — see the complete guide to LaTeX engines if you are unsure whether to use pdfLaTeX, XeLaTeX, or LuaLaTeX for your documents.
// Compile sends LaTeX source to the FormatEx API and returns the compiled PDF bytes.
func (c *Client) Compile(ctx context.Context, req CompileRequest) ([]byte, error) {
if req.Engine == "" {
req.Engine = EnginePDFLaTeX
}
body, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
httpReq, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
c.baseURL+"/compile",
bytes.NewReader(body),
)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("X-API-Key", c.apiKey)
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("execute request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
var apiErr APIError
apiErr.StatusCode = resp.StatusCode
if jsonErr := json.NewDecoder(resp.Body).Decode(&apiErr); jsonErr != nil {
apiErr.Message = http.StatusText(resp.StatusCode)
}
return nil, &apiErr
}
pdf, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response body: %w", err)
}
return pdf, nil
}Key patterns worth noting:
http.NewRequestWithContext— the context is threaded through so callers control cancellation and deadlines- Non-200 responses are decoded into
APIError— you get a structured error, not a raw string defer resp.Body.Close()is placed immediately after checking the error fromDo, not after the status check
Handling Errors and Timeouts in Practice
Here is a complete example that compiles a document, handles specific error cases, and writes the PDF to disk:
package main
import (
"context"
"errors"
"fmt"
"os"
"time"
latexclient "github.com/yourorg/latexclient"
)
func main() {
client := latexclient.New(os.Getenv("FORMATEX_API_KEY"))
source := `\documentclass{article}
\begin{document}
\title{Hello from Go}
\author{FormatEx}
\maketitle
This PDF was compiled via the FormatEx API from a Go service.
\end{document}`
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
pdf, err := client.Compile(ctx, latexclient.CompileRequest{
Source: source,
Engine: latexclient.EnginePDFLaTeX,
})
if err != nil {
var apiErr *latexclient.APIError
if errors.As(err, &apiErr) {
switch apiErr.StatusCode {
case 401:
fmt.Fprintln(os.Stderr, "invalid API key — check FORMATEX_API_KEY")
case 402:
fmt.Fprintln(os.Stderr, "compilation limit reached — upgrade your plan")
case 422:
fmt.Fprintf(os.Stderr, "LaTeX error: %s\n", apiErr.Message)
default:
fmt.Fprintf(os.Stderr, "API error: %v\n", apiErr)
}
} else {
fmt.Fprintf(os.Stderr, "request failed: %v\n", err)
}
os.Exit(1)
}
if err := os.WriteFile("output.pdf", pdf, 0644); err != nil {
fmt.Fprintf(os.Stderr, "write PDF: %v\n", err)
os.Exit(1)
}
fmt.Printf("compiled %d bytes → output.pdf\n", len(pdf))
}The errors.As pattern is the right way to handle typed errors in Go — it works even when the error has been wrapped with %w. Status code 402 indicates you have hit your monthly compilation limit, which is the right signal to surface to your users rather than silently failing. For a full reference of every status code the API can return, see the LaTeX API error codes guide.
Streaming PDFs in an HTTP Handler
Most real use cases do not write to disk — they stream the PDF directly to a browser or downstream client. Here is a Gin handler that proxies a compilation request:
package handlers
import (
"context"
"errors"
"net/http"
"time"
"github.com/gin-gonic/gin"
latexclient "github.com/yourorg/latexclient"
)
type CompileHandler struct {
latex *latexclient.Client
}
func NewCompileHandler(apiKey string) *CompileHandler {
return &CompileHandler{
latex: latexclient.New(apiKey),
}
}
type compileBody struct {
Source string `json:"source" binding:"required"`
Engine latexclient.Engine `json:"engine"`
}
func (h *CompileHandler) Compile(c *gin.Context) {
var body compileBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 90*time.Second)
defer cancel()
pdf, err := h.latex.Compile(ctx, latexclient.CompileRequest{
Source: body.Source,
Engine: body.Engine,
})
if err != nil {
var apiErr *latexclient.APIError
if errors.As(err, &apiErr) {
c.JSON(apiErr.StatusCode, gin.H{"error": apiErr.Message})
return
}
c.JSON(http.StatusBadGateway, gin.H{"error": "compilation service unavailable"})
return
}
c.Header("Content-Disposition", `attachment; filename="document.pdf"`)
c.Data(http.StatusOK, "application/pdf", pdf)
}Notice that we derive the context from c.Request.Context() — if the upstream client disconnects, the compilation request to FormatEx is also cancelled. This avoids burning API quota on abandoned requests. If you are integrating this into a CI/CD pipeline, the context deadline also maps naturally to LaTeX compilation timeouts in CI/CD.
Testing the Client
Because the client is a struct with an interface you can mock, unit testing is straightforward. For integration tests, set FORMATEX_API_KEY in CI and run against the live API — the Free plan's 15 compilations per month is enough for a test suite that runs on pull requests.
For unit tests, define an interface and swap in a mock:
type Compiler interface {
Compile(ctx context.Context, req CompileRequest) ([]byte, error)
}Your handlers accept Compiler, your tests inject a mock, and the real *Client satisfies the interface automatically.
A minimal integration test:
func TestCompileIntegration(t *testing.T) {
apiKey := os.Getenv("FORMATEX_API_KEY")
if apiKey == "" {
t.Skip("FORMATEX_API_KEY not set")
}
client := latexclient.New(apiKey)
pdf, err := client.Compile(context.Background(), latexclient.CompileRequest{
Source: `\documentclass{article}\begin{document}Hello\end{document}`,
})
if err != nil {
t.Fatalf("compile failed: %v", err)
}
if !bytes.HasPrefix(pdf, []byte("%PDF")) {
t.Fatal("response is not a valid PDF")
}
t.Logf("compiled %d bytes", len(pdf))
}The %PDF magic byte check is a fast sanity test that the API returned an actual PDF and not an HTML error page.
Get Started with FormatEx
The client built in this tutorial is around 100 lines of standard-library Go and handles authentication, error typing, context propagation, and PDF streaming. Drop it into any Go service and you eliminate the TeX Live dependency entirely.
To use it in production:
- Sign up at formatex.io — the Free plan gives you 15 compilations per month with no credit card required
- Create an API key from your dashboard — follow API authentication best practices to store and rotate keys securely
- Set
FORMATEX_API_KEYin your environment - Instantiate
latexclient.New(os.Getenv("FORMATEX_API_KEY"))and callCompile
If you need XeLaTeX for Unicode support, LuaLaTeX for advanced font control, or higher monthly limits, the Developer plan at $12/month covers most teams. The API is versioned under /api/v1/ so your client code will not break when we ship new features. For teams hitting rate limits under load, implementing rate limiting and retry logic with exponential backoff in your Go client is the recommended next step.
Related Articles
- The Complete Guide to LaTeX Engines — Understand when to use pdfLaTeX, XeLaTeX, LuaLaTeX, and latexmk before wiring up your Go client
- LaTeX API Error Codes: Complete Guide — Full reference for 422 compile failures, 429 rate limits, and every other status code your client will encounter
- LaTeX API Rate Limiting and Retry Logic — Add exponential backoff and jitter to your Go HTTP client for production resilience
- LaTeX API Authentication Best Practices — Secure API key storage, rotation, and secrets manager integration for Go services
- Why TeX Live Docker Images Are 4 GB — The case for replacing a local TeX Live installation with an API call
\end{article}
\related{posts}




