Overview
flootypst is a small HTTP service that compiles Typst templates to PDF. It exposes two request shapes:
- Multipart mode —
POST /renderwith the template, optional JSON inputs, and any image/asset files in a single multipart body. The PDF is returned synchronously in the response. - URL mode —
POST /render-uriwith 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
| Method | Path | Purpose |
|---|---|---|
| POST | /render | Multipart compile, PDF response |
| POST | /render-uri | URL-driven compile, uploads PDF to a URL |
| GET | /health | Liveness 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
- The
templatefield is the entry-point.typsource. - The
inputsfield is a JSON document that’s exposed to the template assys.inputs.data— see Multipart mode for details. - 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
| Field | Required | Repeatable | Purpose |
|---|---|---|---|
template | yes | no | The entry-point .typ source. Filename becomes the in-bundle path. |
inputs | no | no | JSON document. Exposed to the template as sys.inputs.data (stringified). |
assets | no | yes | Any 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, invalidinputsJSON, 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
| Field | Required | Type | Notes |
|---|---|---|---|
template | yes | string (URL) | HTTPS URL to the entry-point .typ source. |
template_path | no | string | Path the template is known by inside the compile bundle. Defaults to main.typ. |
inputs | no | any JSON value | Exposed to the template as sys.inputs.data (stringified). Same contract as multipart. |
assets | no | array | Each entry has path (in-bundle filename) and url (HTTPS source). |
output | yes | string (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_typeyou pass intogenerate_signed_urlmust match theContent-Typethe uploader sends. flootypst always sendsapplication/pdfon 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
| Capability | Status |
|---|---|
| Read files inside the request bundle | allowed |
Read host filesystem (/etc, etc.) | denied — virtual World |
Import @preview/... packages | denied — package resolver rejects everything |
| Network access during compile | none — compiler runs in a sandboxed child without it |
| Outlive the request | denied — 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:
- HTTPS-only by default. Non-HTTPS schemes are rejected at the URL-validation step (
require_https=true). - 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. - Optional host allowlist.
FLOOTYPST_HOST_ALLOWLISTaccepts a comma-separated list of patterns.*.googleapis.comis supported. When set, any URL whose host doesn’t match is rejected before DNS is even attempted. - No redirect following. A 3xx from an allowed host cannot bounce the request to a forbidden one.
- 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
| Limit | Env var | Default | What happens on hit |
|---|---|---|---|
| Multipart body size | FLOOTYPST_MAX_BODY_BYTES | 32 MiB | 413 |
| Per-URL fetch size | FLOOTYPST_MAX_FETCH_BYTES | 32 MiB | 400 (“too large”) |
| Output PDF size | FLOOTYPST_MAX_OUTPUT_BYTES | 64 MiB | 413 |
| Compile time | FLOOTYPST_COMPILE_TIMEOUT_SECS | 10 s | 408 (worker SIGKILLed) |
| Fetch time | FLOOTYPST_FETCH_TIMEOUT_SECS | 15 s | 408 |
| Upload time | FLOOTYPST_UPLOAD_TIMEOUT_SECS | 30 s | 408 |
| Concurrent compiles | FLOOTYPST_MAX_CONCURRENT_COMPILES | host CPU count | 503 |
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.
| Variable | Default | Purpose |
|---|---|---|
FLOOTYPST_BIND | 0.0.0.0:3000 | Socket address to listen on. |
FLOOTYPST_MAX_BODY_BYTES | 33554432 (32 MiB) | Max size of an incoming multipart body. |
FLOOTYPST_MAX_OUTPUT_BYTES | 67108864 (64 MiB) | Max size of a rendered PDF before the request is rejected. |
FLOOTYPST_MAX_CONCURRENT_COMPILES | host CPU count | Cap on simultaneous worker subprocesses. Surplus requests get 503. |
FLOOTYPST_COMPILE_TIMEOUT_SECS | 10 | Worker 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_SECS | 15 | Per-URL GET timeout (URL mode). |
FLOOTYPST_UPLOAD_TIMEOUT_SECS | 30 | PUT timeout (URL mode). |
FLOOTYPST_MAX_FETCH_BYTES | 33554432 (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_IPS | false | Set true only for local development. |
FLOOTYPST_REQUIRE_HTTPS | true | Allow plain HTTP for outbound fetch/upload. Production: leave true. |
RUST_LOG | info | Standard 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 workeris spawned per/renderor/render-urirequest. The worker reads its bundle from stdin (bincode), writes the PDF to stdout, and exits. - Workers inherit
PR_SET_PDEATHSIG = SIGKILLon Linux so they cannot outlive the parent. - The parent uses
kill_on_dropon 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.