Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Overview

flootypst is a small HTTP service that compiles Typst templates to PDF. It exposes two request shapes:

  • Multipart modePOST /render with the template, optional JSON inputs, and any image/asset files in a single multipart body. The PDF is returned synchronously in the response.
  • URL modePOST /render-uri with a JSON document that points to a publicly-fetchable template, optional asset URLs, and an upload destination. The service downloads everything, compiles, uploads the PDF to the destination, and returns a small JSON ack. Designed for GCS signed URLs in both directions.

Why two modes?

Multipart is the simplest path: one HTTP request in, one PDF out. Good for small templates and quick clients.

URL mode keeps request bodies small (just URLs and inputs) even when the template ships with large images, and avoids streaming a big PDF back through the API tier. The caller signs short-lived URLs against its own storage bucket, hands them to flootypst, and the bytes flow directly between flootypst and the bucket.

What it is not

  • It does not hold credentials for any storage backend. Authorization is delegated entirely to whoever signs the URLs.
  • It does not download Typst packages from @preview/... or anywhere else. Templates must be self-contained.
  • It does not see the host filesystem. Templates and assets you supply are the only files visible to the compiler.

Endpoints

MethodPathPurpose
POST/renderMultipart compile, PDF response
POST/render-uriURL-driven compile, uploads PDF to a URL
GET/healthLiveness probe (returns ok)
GET/docs/This documentation

Quickstart

Compile a one-page PDF with curl.

1. Create a template

// hello.typ
#let d = json.decode(sys.inputs.data)

= Hello, #d.name!

You requested #d.items.len() items:

#for item in d.items [
  - #item
]

2. Create the inputs

{ "name": "Domonkos", "items": ["axum", "typst", "PDFs"] }

Save as hello.inputs.json.

3. Call the API

curl -sS -o hello.pdf \
  -X POST https://typst.floomatik.com/render \
  -F "template=@hello.typ" \
  -F "inputs=<hello.inputs.json"

file hello.pdf
# hello.pdf: PDF document, version 1.7, 1 page(s)

That’s it. Open hello.pdf and you’ll see the rendered list.

What’s happening

  1. The template field is the entry-point .typ source.
  2. The inputs field is a JSON document that’s exposed to the template as sys.inputs.data — see Multipart mode for details.
  3. The response body is the compiled PDF with Content-Type: application/pdf.

For workflows where the template lives in cloud storage, or you want the PDF written straight to a bucket, see URL mode.

Multipart mode

POST /render with Content-Type: multipart/form-data.

Fields

FieldRequiredRepeatablePurpose
templateyesnoThe entry-point .typ source. Filename becomes the in-bundle path.
inputsnonoJSON document. Exposed to the template as sys.inputs.data (stringified).
assetsnoyesAny additional file (image, .typ for imports, data file). Filename = path.

Response

  • 200 — body is the compiled PDF, Content-Type: application/pdf.
  • 400 — malformed request: missing template, invalid inputs JSON, unknown field, etc.
  • 422 — compile error. Body is the Typst diagnostic.
  • 408 — compile took longer than the configured timeout. The worker was killed.
  • 413 — multipart body or resulting PDF exceeded the configured size limit.
  • 503 — server at its concurrent-compile limit. Retry later.

Reading inputs in the template

The inputs field is stringified and exposed as sys.inputs.data. Decode it once at the top of the template:

#let d = json.decode(sys.inputs.data)

= Invoice for #d.customer

#table(
  columns: 2,
  ..for line in d.line_items {
    (line.description, str(line.amount))
  }
)

This single-key contract keeps the protocol simple: arbitrary JSON in, arbitrary structure available in the template.

Imports across multiple files

Send each additional .typ file as an assets field. The filename becomes the path used in #import:

curl -sS -o out.pdf \
  -X POST https://typst.floomatik.com/render \
  -F "template=@main.typ" \
  -F "assets=@components/header.typ;filename=header.typ" \
  -F "assets=@components/footer.typ;filename=footer.typ"
// main.typ
#import "header.typ": page_header
#import "footer.typ": page_footer

#page_header()
= Body content
#page_footer()

Images

Same mechanism. Send each image as an assets field; reference by filename in image():

curl -sS -o report.pdf \
  -X POST https://typst.floomatik.com/render \
  -F "template=@report.typ" \
  -F "assets=@brand/logo.png" \
  -F "assets=@charts/q4.svg"
#image("logo.png", width: 40%)
== Q4 Results
#image("q4.svg")

SVG and PNG are both supported (Typst handles raster and vector). Asset bytes go through verbatim — no transcoding.

Error responses are plain text

A 422 from a broken template looks like:

compile failed: unknown variable: name

Suitable for logging or surfacing back to a UI.

URL mode (GCS-friendly)

POST /render-uri with Content-Type: application/json.

Designed for the common pattern: template + assets live in Google Cloud Storage, the rendered PDF should land back in GCS, and you don’t want to stream megabytes through the API tier. The caller signs short-lived URLs against its own bucket; flootypst never holds GCS credentials.

The same shape works against any storage with HTTPS signed-URL semantics (S3, Azure Blob, R2, etc.).

Request

{
  "template": "https://storage.googleapis.com/your-bucket/main.typ?X-Goog-Signature=...",
  "template_path": "main.typ",
  "inputs": { "customer": "Acme", "amount": 1200.50 },
  "assets": [
    { "path": "logo.png", "url": "https://storage.googleapis.com/your-bucket/logo.png?X-Goog-Signature=..." },
    { "path": "data.csv", "url": "https://storage.googleapis.com/your-bucket/data.csv?X-Goog-Signature=..." }
  ],
  "output": "https://storage.googleapis.com/your-bucket/out/report.pdf?X-Goog-Signature=..."
}

Fields

FieldRequiredTypeNotes
templateyesstring (URL)HTTPS URL to the entry-point .typ source.
template_pathnostringPath the template is known by inside the compile bundle. Defaults to main.typ.
inputsnoany JSON valueExposed to the template as sys.inputs.data (stringified). Same contract as multipart.
assetsnoarrayEach entry has path (in-bundle filename) and url (HTTPS source).
outputyesstring (URL)HTTPS URL to PUT the resulting PDF. The signed URL must permit PUT with application/pdf.

Response

{
  "url": "https://storage.googleapis.com/.../out.pdf?...",
  "bytes": 13942,
  "compile_ms": 87,
  "upload_ms": 134
}
  • 200 — PDF was compiled and successfully uploaded. Body is the JSON above.
  • 400 — bad JSON, invalid URL, scheme other than HTTPS, host blocked by allowlist, asset exceeded fetch size cap.
  • 408 — fetch or compile timed out.
  • 422 — compile error.
  • 500 — upstream returned non-2xx on GET or PUT, network error, DNS resolved only to disallowed addresses.

Generating signed URLs (Python / GCS)

from datetime import timedelta
from google.cloud import storage

client = storage.Client()
bucket = client.bucket("your-bucket")

def signed_get(name: str) -> str:
    return bucket.blob(name).generate_signed_url(
        version="v4",
        expiration=timedelta(minutes=15),
        method="GET",
    )

def signed_put(name: str) -> str:
    return bucket.blob(name).generate_signed_url(
        version="v4",
        expiration=timedelta(minutes=15),
        method="PUT",
        content_type="application/pdf",
    )

payload = {
    "template": signed_get("templates/invoice.typ"),
    "template_path": "invoice.typ",
    "inputs": {"customer": "Acme", "amount": 1200.50},
    "assets": [
        {"path": "logo.png", "url": signed_get("brand/logo.png")},
    ],
    "output": signed_put("renders/invoice-2026-05.pdf"),
}

A few details to get right with GCS PUT signing:

  • The content_type you pass into generate_signed_url must match the Content-Type the uploader sends. flootypst always sends application/pdf on the upload.
  • The default GCS lifetime (15 minutes here) just needs to cover the request — flootypst attempts the upload immediately after the compile finishes.

End-to-end with curl

curl -sS -X POST https://typst.floomatik.com/render-uri \
  -H 'Content-Type: application/json' \
  --data @payload.json

Where payload.json is the request shown above.

Why a separate endpoint instead of content-type dispatch?

To keep each handler’s contract explicit. POST /render is the “give me a PDF” endpoint; POST /render-uri is the “fetch, render, store” endpoint. Different success responses (PDF bytes vs. JSON ack), different error sources (multipart parse vs. URL fetch), and different size profiles for the request body.

Security model

flootypst executes Typst code supplied by the client. The threat model assumes the template author is untrusted.

What the compiler can and cannot do

CapabilityStatus
Read files inside the request bundleallowed
Read host filesystem (/etc, etc.)denied — virtual World
Import @preview/... packagesdenied — package resolver rejects everything
Network access during compilenone — compiler runs in a sandboxed child without it
Outlive the requestdenied — PR_SET_PDEATHSIG, kill_on_drop, SIGKILL on timeout

The Typst compiler runs in a child process (flootypst worker) so it can be hard-killed on timeout and so a runaway template cannot pin a parent thread.

SSRF protection (URL mode)

The URL-mode endpoint fetches caller-supplied URLs. Defenses:

  1. HTTPS-only by default. Non-HTTPS schemes are rejected at the URL-validation step (require_https=true).
  2. Custom DNS resolver. Resolution happens inside flootypst; any result that is loopback, private, link-local, multicast, broadcast, IPv6 ULA / link-local, the GCE/EC2 metadata IP 169.254.169.254, carrier-grade NAT, or reserved is dropped. If the entire resolution set is unsafe, the request is refused at connect time. This closes the typical DNS-rebinding window.
  3. Optional host allowlist. FLOOTYPST_HOST_ALLOWLIST accepts a comma-separated list of patterns. *.googleapis.com is supported. When set, any URL whose host doesn’t match is rejected before DNS is even attempted.
  4. No redirect following. A 3xx from an allowed host cannot bounce the request to a forbidden one.
  5. Size caps. Fetched bodies are streamed and aborted when they exceed FLOOTYPST_MAX_FETCH_BYTES.

A typical production config locks the allowlist down to your storage host(s):

FLOOTYPST_HOST_ALLOWLIST=storage.googleapis.com

Resource limits

LimitEnv varDefaultWhat happens on hit
Multipart body sizeFLOOTYPST_MAX_BODY_BYTES32 MiB413
Per-URL fetch sizeFLOOTYPST_MAX_FETCH_BYTES32 MiB400 (“too large”)
Output PDF sizeFLOOTYPST_MAX_OUTPUT_BYTES64 MiB413
Compile timeFLOOTYPST_COMPILE_TIMEOUT_SECS10 s408 (worker SIGKILLed)
Fetch timeFLOOTYPST_FETCH_TIMEOUT_SECS15 s408
Upload timeFLOOTYPST_UPLOAD_TIMEOUT_SECS30 s408
Concurrent compilesFLOOTYPST_MAX_CONCURRENT_COMPILEShost CPU count503

What is not included

  • No auth on the API itself. flootypst is meant to sit behind your own gateway / mTLS / API-key layer.
  • No rate limiting beyond the concurrency cap. Rate-limit upstream.
  • No audit log of which templates rendered what. The service is stateless.

Configuration

Everything is configured via environment variables. Defaults are production-safe except for the lack of authentication.

VariableDefaultPurpose
FLOOTYPST_BIND0.0.0.0:3000Socket address to listen on.
FLOOTYPST_MAX_BODY_BYTES33554432 (32 MiB)Max size of an incoming multipart body.
FLOOTYPST_MAX_OUTPUT_BYTES67108864 (64 MiB)Max size of a rendered PDF before the request is rejected.
FLOOTYPST_MAX_CONCURRENT_COMPILEShost CPU countCap on simultaneous worker subprocesses. Surplus requests get 503.
FLOOTYPST_COMPILE_TIMEOUT_SECS10Worker is SIGKILLed past this. Request returns 408.
FLOOTYPST_WORKER_PATH$(current_exe)Override for the worker binary. Useful when running under a supervisor.
FLOOTYPST_FETCH_TIMEOUT_SECS15Per-URL GET timeout (URL mode).
FLOOTYPST_UPLOAD_TIMEOUT_SECS30PUT timeout (URL mode).
FLOOTYPST_MAX_FETCH_BYTES33554432 (32 MiB)Per-URL body size cap (URL mode).
FLOOTYPST_HOST_ALLOWLIST(unset = allow all public hosts)Comma-separated host patterns. *.googleapis.com supported.
FLOOTYPST_ALLOW_PRIVATE_IPSfalseSet true only for local development.
FLOOTYPST_REQUIRE_HTTPStrueAllow plain HTTP for outbound fetch/upload. Production: leave true.
RUST_LOGinfoStandard tracing-subscriber filter.

Common configurations

Locked-down GCS deployment

FLOOTYPST_BIND=127.0.0.1:3000
FLOOTYPST_HOST_ALLOWLIST=storage.googleapis.com
FLOOTYPST_COMPILE_TIMEOUT_SECS=30
FLOOTYPST_MAX_OUTPUT_BYTES=33554432

Multi-cloud deployment

FLOOTYPST_HOST_ALLOWLIST=storage.googleapis.com,*.amazonaws.com,*.r2.cloudflarestorage.com

Local development against a fixture server

FLOOTYPST_ALLOW_PRIVATE_IPS=true
FLOOTYPST_REQUIRE_HTTPS=false

Operations

Process model

  • One flootypst serve (the default subcommand) is the long-running HTTP server.
  • One short-lived flootypst worker is spawned per /render or /render-uri request. The worker reads its bundle from stdin (bincode), writes the PDF to stdout, and exits.
  • Workers inherit PR_SET_PDEATHSIG = SIGKILL on Linux so they cannot outlive the parent.
  • The parent uses kill_on_drop on the child handle, so a client disconnect or request cancellation kills the worker too.

Health checks

GET /health returns 200 ok. It does not touch any state or spawn workers, so it’s safe to hit at any rate.

Concurrency

Each in-flight render holds one slot of a semaphore sized by FLOOTYPST_MAX_CONCURRENT_COMPILES. The semaphore is acquired with try_acquire — overflow returns 503 server busy immediately rather than queueing. Put rate limiting / queueing in the upstream layer if you need it.

Sizing

A single worker peaks at one CPU during compile and roughly 100–300 MB resident, scaling with document complexity. Memory falls back to ~0 between requests (the worker exits). For sustained throughput, the concurrent-compile cap should match CPU count; for headroom, run at cpu_count - 1.

Logs

Logs go to stdout via tracing. Set RUST_LOG=flootypst=debug,tower_http=info for request-level detail.

What doesn’t exist yet

  • Metrics endpoint (Prometheus or otherwise).
  • Per-tenant accounting / quotas.
  • Async job mode with callbacks. Today every request blocks until the PDF is uploaded.

Open an issue against the repo if you need any of these.