> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.cloudraker.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.cloudraker.com/_mcp/server.

# Compose

`POST /v1/compose` takes a saved template and the data to put in it. It returns a PDF. The template is a [Typst](https://typst.app) 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](#build-a-template) below.

```bash title="curl"
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 }
  }'
```

```ts title="TypeScript"
const res = await fetch("https://api.cloudraker.com/v1/compose", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.CLOUDRAKER_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    template: "invoice",
    data: { customer: "Acme Manufacturing Co.", number: "0042", total: 1240 },
  }),
});

const file = await res.json();
console.log(file.id, file.templateHash);
```

```python title="Python"
import os, requests

res = requests.post(
    "https://api.cloudraker.com/v1/compose",
    headers={"Authorization": f"Bearer {os.environ['CLOUDRAKER_API_KEY']}"},
    json={
        "template": "invoice",
        "data": {"customer": "Acme Manufacturing Co.", "number": "0042", "total": 1240},
    },
)

file = res.json()
print(file["id"], file["templateHash"])
```

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.

```bash
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](/developers/files) object:

```json
{
  "object": "file",
  "id": "6913da38-6d0c-4b6f-9a1e-2b0f6f2b8a41",
  "name": "invoice.pdf",
  "mimeType": "application/pdf",
  "status": "ready",
  "createdAt": "2026-08-15T10:22:04.118Z",
  "urls": { "content": "https://cdn.cloudraker.com/…/latest?token=…" },
  "composeTemplate": "cfg_01KYD75GGJ8QK6HN2TVZ0ABCDE",
  "templateHash": "9f2c1b…",
  "composedAt": "2026-08-15T10:22:04.118Z"
}
```

| Field                 | What it is                                                                |
| --------------------- | ------------------------------------------------------------------------- |
| `id` / `urls.content` | The stored PDF and its download location.                                 |
| `composeTemplate`     | The config that produced it.                                              |
| `templateHash`        | The sha-256 of the exact bundle. See [Reproducibility](#reproducibility). |
| `composedAt`          | The time of the render.                                                   |

## Configuration

`POST /v1/compose`:

| Field      | Type            | What it does                                                                              |
| ---------- | --------------- | ----------------------------------------------------------------------------------------- |
| `template` | string          | **Required.** The compose config id **or** slug.                                          |
| `data`     | object          | **Required.** Your values. The API validates them against the template's `config.schema`. |
| `output`   | `file` \| `raw` | `file` (default) stores the PDF and returns the file object. `raw` returns the bytes.     |

## Templates are configs

A compose template is a [saved config](/capabilities/actions) 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:

| Field      | Type                    | What it is                                                                                                                      |
| ---------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `schema`   | JSON Schema             | The contract your `data` must satisfy. Root `type` must be `object`. Any dialect, up to 64 KB. If you omit it, any data passes. |
| `main`     | string                  | The entry point: the bundle file that the engine compiles. Must be one of `files`.                                              |
| `files`    | array of file ids       | The bundle, in manifest order. Max 20.                                                                                          |
| `examples` | array of `{name, data}` | Named sample payloads for [preview](#preview-and-lint). 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`.

```bash
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](/capabilities/templates) 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:

```bash title="Source text"
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."
  }'
```

```bash title="Binary asset"
# 1. register the asset — the answer carries uploadUrl
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": "logo.png", "mimeType": "image/png" }'

# 2. PUT the bytes there with the SAME Content-Type
curl -X PUT "<uploadUrl>" \
  -H "Content-Type: image/png" \
  --data-binary @./logo.png
```

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

```bash
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

| Call                                                        | What it does                                                           |
| ----------------------------------------------------------- | ---------------------------------------------------------------------- |
| `GET /v1/compose/configs/{idOrSlug}/files`                  | Lists the bundle in manifest order, with sizes.                        |
| `GET /v1/compose/configs/{idOrSlug}/files/{fileId}/content` | Downloads 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.

```typst
// right
= Invoice #data.number
#for line in data.items [ #line.label #h(1fr) #line.amount ]

// wrong — the bundle is refused
#let data = json("data.json")
```

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

| Family        | Use                                           |
| ------------- | --------------------------------------------- |
| `Roboto`      | Sans — the default                            |
| `Arimo`       | Sans, metric-compatible with Arial            |
| `Noto Serif`  | Serif                                         |
| `Tinos`       | Serif, metric-compatible with Times New Roman |
| `Roboto Mono` | Monospace                                     |

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:

```bash
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" }'
```

```typst
#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](#preview-and-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:

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

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.

```bash
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"
  }'
```

| Field      | Type                           | What it does                                                                                                                            |
| ---------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- |
| `template` | string                         | **Required.** The config id or slug.                                                                                                    |
| `items[]`  | array of `{data, name?}`, 2–25 | **Required.** One rendered document per item.                                                                                           |
| `output`   | `files` \| `zip`               | `files` (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](/developers/spaces). 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

| Limit                     | Value                                                               |
| ------------------------- | ------------------------------------------------------------------- |
| Files per bundle          | 20                                                                  |
| Inline source (`content`) | 2 MB                                                                |
| `config.schema`           | 64 KB, root `type: "object"`                                        |
| Examples                  | 10, 256 KB of `data` each                                           |
| Batch items               | 2–25                                                                |
| Batch output              | 80 MB per call                                                      |
| Rate limit                | The shared `/v1` floor — see [Rate limits](/developers/rate-limits) |

## Errors

| Code                      | Status | Why                                                                                                                                 |
| ------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------- |
| `invalid_data`            | 422    | The `data` does not satisfy the template's schema. `errors[]` carries every failing instance path, and the item `index` in a batch. |
| `config_incomplete`       | 422    | The template has no files, or no `main`.                                                                                            |
| `main_not_in_files`       | 422    | `main` names a file that is not in the bundle.                                                                                      |
| `template_render_failed`  | 422    | The template did not compile. The message carries the compiler diagnostics. Run [lint](#preview-and-lint) first.                    |
| `template_file_not_found` | 422    | A bundle entry no longer resolves to a file in your template library.                                                               |
| `capability_mismatch`     | 400    | The reference is a config of another capability.                                                                                    |
| `render_unavailable`      | 502    | The API could not reach the renderer. Retryable.                                                                                    |

## Next steps

#### [Saved configs](/capabilities/actions)

How every capability's config library works: listing, slugs, and pagination.

#### [Templates](/capabilities/templates)

The org-level file library a compose bundle draws from.

#### [Sign](/capabilities/sign)

Send the composed document out for signature.

#### [Files](/developers/files)

Download, list, and manage the PDFs compose stores.