Compose
POST /v1/compose takes a saved template and the data to put in it. It returns a PDF. The template is a Typst bundle: one entry-point source, its partials, and its assets. The API stores it as a saved config under a name and a slug. A call then becomes {"template": "invoice", "data": {…}}.
Every other capability reads documents. Compose writes them: invoices, contracts, certificates, and letters. The same layout produces one PDF per row of data.
How it works
- Create a compose config (
POST /v1/compose/configs) with a name, a JSON Schema for your data, and the bundle files. - Upload the sources into its bundle (
PUT /v1/compose/configs/{idOrSlug}/files). Name the entry point inconfig.main. POST /v1/composevalidates yourdataagainst the template’s schema before it renders anything. A missing field returns a422with the exact instance paths. The API never renders a wrong document.- The API stages and compiles the bundle.
output: "file"(the default) stores the PDF in your corpus.output: "raw"streams the bytes back and stores nothing. - The stored file carries
templateHash: the identity of the exact bundle that produced it.
Compose is not a run. The API queues nothing. There is no run object, no TTL, and no webhook. The request renders and answers. Do not poll.
Quickstart
Render a saved invoice template. The template already exists. See Build a template below.
To get the bytes instead of a stored file, add "output": "raw". The response is then the PDF itself (Content-Type: application/pdf, 200). The API writes nothing to your corpus.
Example response
output: "file" answers 201 with a file object:
Configuration
POST /v1/compose:
Templates are configs
A compose template is a saved config with capability: "compose". Manage it at /v1/compose/configs like every other library: POST, GET, PATCH, DELETE, and a paginated GET /v1/compose/configs.
Its config is the whole template:
Every field is optional. An editor can create the config first and fill it in later. The render calls own the completeness check: a template with no files or no main answers 422 config_incomplete.
A config from another capability answers 400 capability_mismatch on the compose routes. A compose config passed as another verb’s action gets the same refusal. A compose config is never dispatchable as an action. It renders; it does not run.
Build a template
Add the files
PUT /v1/compose/configs/{idOrSlug}/files registers a file in your organization’s template library and puts it in this bundle under name. If the bundle already holds a file under that name, the call swaps it out. The API detaches the replaced file but never deletes it, so rendered documents keep working.
Two shapes:
{name, content} stores the text for you (up to 2 MB). Use it for the .typ sources. {name, mimeType} is for binary assets: images, and font files (see below).
Name the entry point
The upload does not set main. Set it on the config:
main must name a file that is in files. Otherwise the call answers 422 main_not_in_files.
Manage the bundle
If you delete the file that main names, the template becomes unrenderable. The call answers 422 unless the same call names the new entry point: DELETE …/files/{fileId}?main=cover.typ.
Writing the Typst template
Two engine rules cause the most errors. Learn them first.
The engine auto-binds data. Never write #let data. The engine prepends the binding itself. Your sources reference data.customer, data.items, and data.total directly. If you declare your own data, it shadows the real payload. The engine then rejects the template with data_redeclaration before it compiles.
Fonts come from a curated set. The engine always carries five families. #set text(font: …) resolves against them:
To use your brand’s typeface, ship it in the bundle. Any .ttf, .otf, or .ttc file you add to a template registers as a font family under its own name. This works per render and offline:
If a family resolves to neither a curated font nor a bundled one, lint reports it under fonts.unresolved.
The renderer does not resolve @preview/… package imports. The renderer is offline by design. Ship what you need as a .typ partial in the bundle and #import "partial.typ" instead.
Preview and lint
Two editor routes. Neither stores anything.
POST /v1/compose/configs/{idOrSlug}/preview renders one of the saved examples and returns the PDF bytes. {"example": "Acme"} picks one by name. Without it, the API uses the first example. A template with no examples still previews: it renders with {}. That is sufficient for a fresh template’s static content.
POST /v1/compose/configs/{idOrSlug}/lint compiles for diagnostics only:
Empty diagnostics with nothing missing and nothing unresolved means the template renders.
Batch
POST /v1/compose/batch renders the same template once per item. One call produces a run of invoices. 2 to 25 items.
An item’s name names its file (<name>.pdf). Without one, the API numbers the files from the template slug. A name must be a bare file name: no /, no \, no .., no leading dot (422 invalid_item_name). Two items may not produce the same file name (422 duplicate_item_names).
All or nothing. The API validates every item’s data before it renders anything. If one item fails, nothing renders. The call answers 422 invalid_data with an errors[] that names the failing item index and instance path.
A batch renders at most 80 MB of PDFs per call. Past that, the call answers 413 output_too_large. Split the batch.
Reproducibility
templateHash is the sha-256 over the bundle’s manifest: every file’s name and content hash, plus main. It identifies the exact sources that produced a PDF.
Two documents with the same templateHash came off the same bundle. Change one character in a partial, swap the logo, or point main elsewhere, and the next render carries a different hash. Store the hash next to your generated document. You can then prove years later which template version a customer received.
Edits to a config never rewrite history. The files a render used stay in your template library after you replace or unlink them. Deleting a config does not touch the documents it produced.
Compose into a space
The same two calls exist under a space. They put the output there instead of in the hidden API workspace:
The bodies and answers are identical. The API writes composed files beside their template source (parentFileId points at main). It never indexes them for search.