FormaTeX

\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.

·9 min read·
Calling the LaTeX API from Go

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 (pdflatex vs xelatex vs lualatex) 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:

Concernexec.Command("pdflatex")FormatEx API
Docker image size+4 GB0 MB added
ConcurrencyFile locking requiredStateless HTTP
Engine supportOne binary per engineSingle endpoint, engine param
Error messagesParse raw stderrStructured JSON
Timeout controlOS process timeoutHTTP context deadline
MaintenanceTeX Live updatesAPI 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:

go
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:

  • APIError implements error so callers can use errors.As to inspect status codes
  • WithTimeout returns a copy rather than mutating — safe for concurrent use
  • The Engine type 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.

go
// 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:

  1. http.NewRequestWithContext — the context is threaded through so callers control cancellation and deadlines
  2. Non-200 responses are decoded into APIError — you get a structured error, not a raw string
  3. defer resp.Body.Close() is placed immediately after checking the error from Do, 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:

go
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:

go
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:

go
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:

go
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:

  1. Sign up at formatex.io — the Free plan gives you 15 compilations per month with no credit card required
  2. Create an API key from your dashboard — follow API authentication best practices to store and rotate keys securely
  3. Set FORMATEX_API_KEY in your environment
  4. Instantiate latexclient.New(os.Getenv("FORMATEX_API_KEY")) and call Compile

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.

\end{article}

Back to blog

\related{posts}

One quick thing

We track anonymous usage — page views, feature usage, compilation events — to understand what works and what doesn't. No ads, no personal data, no third-party sharing.

Cookie policy