Compose

Render a saved document template with your own JSON data, one document or a batch.
View as Markdown

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

  1. Create a compose config (POST /v1/compose/configs) with a name, a JSON Schema for your data, and the bundle files.
  2. Upload the sources into its bundle (PUT /v1/compose/configs/{idOrSlug}/files). Name the entry point in config.main.
  3. POST /v1/compose validates your data against the template’s schema before it renders anything. A missing field returns a 422 with the exact instance paths. The API never renders a wrong document.
  4. 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.
  5. 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.

$curl -X POST https://api.cloudraker.com/v1/compose \
> -H "Authorization: Bearer $CLOUDRAKER_API_KEY" \
> -H "Content-Type: application/json" \
> -d '{
> "template": "invoice",
> "data": { "customer": "Acme Manufacturing Co.", "number": "0042", "total": 1240 }
> }'

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.

$curl -X POST https://api.cloudraker.com/v1/compose \
> -H "Authorization: Bearer $CLOUDRAKER_API_KEY" \
> -H "Content-Type: application/json" \
> -d '{ "template": "invoice", "data": { "total": 1240 }, "output": "raw" }' \
> --output invoice.pdf

Example response

output: "file" answers 201 with a file object:

1{
2 "object": "file",
3 "id": "6913da38-6d0c-4b6f-9a1e-2b0f6f2b8a41",
4 "name": "invoice.pdf",
5 "mimeType": "application/pdf",
6 "status": "ready",
7 "createdAt": "2026-08-15T10:22:04.118Z",
8 "urls": { "content": "https://cdn.cloudraker.com/…/latest?token=…" },
9 "composeTemplate": "cfg_01KYD75GGJ8QK6HN2TVZ0ABCDE",
10 "templateHash": "9f2c1b…",
11 "composedAt": "2026-08-15T10:22:04.118Z"
12}
FieldWhat it is
id / urls.contentThe stored PDF and its download location.
composeTemplateThe config that produced it.
templateHashThe sha-256 of the exact bundle. See Reproducibility.
composedAtThe time of the render.

Configuration

POST /v1/compose:

FieldTypeWhat it does
templatestringRequired. The compose config id or slug.
dataobjectRequired. Your values. The API validates them against the template’s config.schema.
outputfile | rawfile (default) stores the PDF and returns the file object. raw returns the bytes.

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:

FieldTypeWhat it is
schemaJSON SchemaThe contract your data must satisfy. Root type must be object. Any dialect, up to 64 KB. If you omit it, any data passes.
mainstringThe entry point: the bundle file that the engine compiles. Must be one of files.
filesarray of file idsThe bundle, in manifest order. Max 20.
examplesarray of {name, data}Named sample payloads for preview. Max 10, 256 KB of data each.

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.

$curl -X POST https://api.cloudraker.com/v1/compose/configs \
> -H "Authorization: Bearer $CLOUDRAKER_API_KEY" \
> -H "Content-Type: application/json" \
> -d '{
> "name": "Invoice",
> "config": {
> "schema": {
> "type": "object",
> "required": ["customer", "total"],
> "properties": {
> "customer": { "type": "string" },
> "number": { "type": "string" },
> "total": { "type": "number" }
> }
> },
> "examples": [
> { "name": "Acme", "data": { "customer": "Acme", "number": "0042", "total": 1240 } }
> ]
> }
> }'

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:

$curl -X PUT https://api.cloudraker.com/v1/compose/configs/invoice/files \
> -H "Authorization: Bearer $CLOUDRAKER_API_KEY" \
> -H "Content-Type: application/json" \
> -d '{
> "name": "invoice.typ",
> "content": "#set text(font: \"Roboto\")\n= Invoice #data.number\n#data.customer owes #data.total EUR."
> }'

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

$curl -X PATCH https://api.cloudraker.com/v1/compose/configs/invoice \
> -H "Authorization: Bearer $CLOUDRAKER_API_KEY" \
> -H "Content-Type: application/json" \
> -d '{ "config": { "main": "invoice.typ", "files": ["<file id>", "<logo id>"] } }'

main must name a file that is in files. Otherwise the call answers 422 main_not_in_files.

Manage the bundle

CallWhat it does
GET /v1/compose/configs/{idOrSlug}/filesLists the bundle in manifest order, with sizes.
GET /v1/compose/configs/{idOrSlug}/files/{fileId}/contentDownloads one file’s bytes. This is the .typ source an editor loads.
DELETE /v1/compose/configs/{idOrSlug}/files/{fileId}Unlinks a file. The file stays in your template library.

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.

1// right
2= Invoice #data.number
3#for line in data.items [ #line.label #h(1fr) #line.amount ]
4
5// wrong — the bundle is refused
6#let data = json("data.json")

Fonts come from a curated set. The engine always carries five families. #set text(font: …) resolves against them:

FamilyUse
RobotoSans — the default
ArimoSans, metric-compatible with Arial
Noto SerifSerif
TinosSerif, metric-compatible with Times New Roman
Roboto MonoMonospace

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:

$curl -X PUT https://api.cloudraker.com/v1/compose/configs/invoice/files \
> -H "Authorization: Bearer $CLOUDRAKER_API_KEY" \
> -H "Content-Type: application/json" \
> -d '{ "name": "AcmeSans-Regular.ttf", "mimeType": "font/ttf" }'
1#set text(font: "Acme Sans") // the family name inside the .ttf, not the file name

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:

1{
2 "diagnostics": [
3 { "severity": "error", "message": "unknown field: totl", "file": "invoice.typ", "line": 12, "column": 3 }
4 ],
5 "assets": { "referenced": ["partial.typ", "logo.png"], "missing": ["logo.png"] },
6 "fonts": { "used": ["Roboto", "Acme Sans"], "unresolved": ["Acme Sans"] }
7}

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.

$curl -X POST https://api.cloudraker.com/v1/compose/batch \
> -H "Authorization: Bearer $CLOUDRAKER_API_KEY" \
> -H "Content-Type: application/json" \
> -d '{
> "template": "invoice",
> "items": [
> { "name": "acme-0042", "data": { "customer": "Acme", "total": 1240 } },
> { "name": "globex-0043", "data": { "customer": "Globex", "total": 990 } }
> ],
> "output": "files"
> }'
FieldTypeWhat it does
templatestringRequired. The config id or slug.
items[]array of {data, name?}, 2–25Required. One rendered document per item.
outputfiles | zipfiles (default) stores each PDF and returns the list. zip stores one archive that holds every PDF and returns that single file.

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:

POST /v1/spaces/{spaceId}/compose
POST /v1/spaces/{spaceId}/compose/batch

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.

Limits

LimitValue
Files per bundle20
Inline source (content)2 MB
config.schema64 KB, root type: "object"
Examples10, 256 KB of data each
Batch items2–25
Batch output80 MB per call
Rate limitThe shared /v1 floor — see Rate limits

Errors

CodeStatusWhy
invalid_data422The data does not satisfy the template’s schema. errors[] carries every failing instance path, and the item index in a batch.
config_incomplete422The template has no files, or no main.
main_not_in_files422main names a file that is not in the bundle.
template_render_failed422The template did not compile. The message carries the compiler diagnostics. Run lint first.
template_file_not_found422A bundle entry no longer resolves to a file in your template library.
capability_mismatch400The reference is a config of another capability.
render_unavailable502The API could not reach the renderer. Retryable.

Next steps