---
title: "PDF documents"
description: "Render PDFs from layouts and templates in the reader's language with the built-in renderer, Gotenberg or DocRaptor; the rendering half of documents."
section: "interfaces"
status: "Preview · ships as a beta API"
spec: "documents"
canonical: "https://mainsdk.dev/docs/interfaces/pdf"
updated: "2026-10-28"
---

# PDF documents

> Render PDFs from layouts and templates in the reader's language with the built-in renderer, Gotenberg or DocRaptor; the rendering half of documents.

Status: Preview · ships as a beta API · Canonical: https://mainsdk.dev/docs/interfaces/pdf · Updated: 2026-10-28

`pdf` renders invoices, receipts, certificates and contracts from templates, with text, money and dates in the reader's language through [`i18n`](https://mainsdk.dev/docs/interfaces/i18n).

The real call is `documents.render`; `pdf.render(template, data)` is an alias of it, kept for the short name.

> **Note:** Built for 0.4 (spec `documents`, ADR-0058). It runs from the repository and is not published to npm yet. `pdf.render` is deprecated from documents@2 in favour of `documents.render`, and logs a note once. Signing is planned for phase 3 and has no code. The `gotenberg` and `docraptor` drivers are tested on recorded responses, not the live services. Reading documents is on [Document reading](https://mainsdk.dev/docs/interfaces/documents).

## At a glance

- Status: Preview · ships as a beta API
- Phase: 1 (Core, public beta)
- Milestone: 0.4 Hardening
- Plan: free in your app (Apache-2.0)
- Spec: https://github.com/hubxero/mainsdk/blob/develop/docs/specs/documents.md
- Category: Files, sharing and media

Calls:

- `pdf.render(template, data)`

In main.yaml:

```yaml
pdf:
  driver: builtin
```

Guarantees, whatever the driver:

- Localised through i18n
- Rendered without a browser by the built-in renderer
- Output stored through files in the tenant's region


## Drivers

Pick one in main.yaml with `pdf.driver`.

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| Built in | `builtin` | yes |  |
| DocRaptor | `docraptor` |  |  |
| Gotenberg | `gotenberg` |  |  |
| Documenso | `documenso` |  | planned driver |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-DOC-0011` | check | A locale with a complex script renders with the `builtin` renderer, which doesn't shape it |
| `MAIN-DOC-0007` | runtime | A render layout or template is invalid (400) |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| Render from a template | 1 | 0.4 | `documents#render` |
| HTML to PDF through Gotenberg or DocRaptor | 1 | 0.4 | `documents#render-drivers-checks` |
| Sign a document | 3 | 1.2 | `signed-approvals` |

## MainSDK Cloud

Everything on this page is free in your app.


## Quick start

```yaml title="main.yaml"
documents:
  render: { driver: builtin }
permissions:
  disk:
    read: [templates]
```

```ts title="lib/invoice.ts"
import { documents } from "mainsdk";

export async function invoiceFor(order: { number: string }, userId: string, locale: string) {
  return documents.render("invoice", order, { owner: userId, locale });
}
```

`templates/invoice.pdf.yaml` holds the layout. The call returns a `FileRef`: the PDF is already stored through [`files`](https://mainsdk.dev/docs/interfaces/files), owned by `owner`, in the tenant's region.

## Templates and layouts

`documents.render` takes either a template name or a layout object.

```yaml title="templates/invoice.pdf.yaml"
page: A4                       # A4 | Letter
margin: 20mm
body:
  - text: "Invoice {{number}}"
    style: h1                  # h1 | h2 | body | small
  - table:
      head: ["Item", "Qty", "Price"]
      rows:
        each: items            # repeats the row for every item; the header repeats on each page
        cells: ["{{name}}", "{{qty}}", "{{price | money:currency}}"]
  - text: "Total {{total}}"
    style: h2
```

- Elements are `text`, `table`, `image` (a `FileRef`), `spacer` and `pageBreak`.
- `{{path}}` reads from your data. A missing value fails with `MAIN-DOC-0007`; nothing is evaluated.
- A value written as `{ money: 125000, currency: "INR" }`, `{ number: 4 }` or `{ date: "2026-10-27" }` is formatted for the locale. The filter `{{price | money:currency}}` does the same for a plain number.
- The template is found along the locale chain: `templates/<locale>/<name>.pdf.yaml`, then the fallbacks, then `templates/<name>.pdf.yaml`. Declare the folder under `permissions.disk.read`.
- Page numbers ("Page 1 of 2") are added for you.

## Configuration

```yaml title="main.yaml"
documents:
  render:
    driver: builtin                       # builtin | gotenberg | docraptor
    fonts: [assets/fonts/Inter.ttf]       # embedded and subset per document
    url: https://gotenberg.example.com    # gotenberg only
    key: secret:DOCRAPTOR_API_KEY         # docraptor only
permissions:
  disk:
    read: [templates, assets/fonts]
```

| Key | Default | What it does |
| --- | --- | --- |
| `documents.render.driver` | `builtin` | The renderer |
| `documents.render.fonts` | none | TrueType fonts to embed. The first is the default; set `font:` in a layout to pick another |
| `documents.render.url` | none | Where your Gotenberg runs |
| `documents.render.key` | none | The DocRaptor key, as a `secret:` reference |

Without a font the built-in renderer uses a standard PDF font, which has no rupee sign, for example. Embed a TrueType font that has the characters you print.

## What you get

- PDFs from a layout or a template, rendered by the built-in renderer, Gotenberg or DocRaptor.
- Text, money and dates in the reader's language, with a missing translation caught by the `i18n` check.
- Output stored through [`files`](https://mainsdk.dev/docs/interfaces/files) with an owner, ready to share with an expiring link.
- Template values sanitized before an HTML driver sees them, and no fetches outside `network.allow`.

## Examples

### Render an invoice for a signed-in customer

```ts title="lib/customer-invoice.ts"
import { auth, documents, models, s } from "mainsdk";

const { orders } = models({
  orders: s.object({ item: s.string(), total: s.int(), locale: s.string() }),
}); // declared once in lib/orders.ts, shown inline so the example stands alone

export async function customerInvoice(req: Request, orderId: string) {
  const session = await auth.require(req);
  const order = await orders.for(session.user).get(orderId);
  return documents.render("invoice", { ...order }, { owner: session.user, locale: order.locale });
}
```

`orders.for(session.user)` means a customer can only render invoices for their own orders.

### Render from a layout in code

```ts title="lib/receipt-pdf.ts"
import { documents } from "mainsdk";

export function receipt(userId: string, lines: string[][]) {
  return documents.render(
    { page: "A4", body: [{ text: "Receipt", style: "h1" }, { table: { head: ["Item", "Qty"], rows: lines } }] },
    {},
    { owner: userId, title: "Receipt" },
  );
}
```

### Store and share a certificate

```ts title="lib/certificates.ts"
import { documents, share } from "mainsdk";

export async function issueCertificate(user: { id: string; locale: string }, course: { name: string }) {
  const ref = await documents.render("certificate", { course }, { owner: user.id, locale: user.locale });
  return share(ref, { expiresIn: "30d" });
}
```

Files and links go through getqr.id; see [File uploads](https://mainsdk.dev/docs/interfaces/files) and [Share links and QR codes](https://mainsdk.dev/docs/interfaces/share).

### Sign a contract (planned)

Signing a rendered PDF is planned for phase 3 and has no code. A signing driver (Documenso) is named in the catalog.

## Errors and checks

| Code | When | Fix |
| --- | --- | --- |
| `MAIN-DOC-0007` | The layout, template, font or a value is missing or invalid | Add `templates/<name>.pdf.yaml`, declare the folder under `permissions.disk.read`, or give the value the template names |
| `MAIN-FILES-0002` | No `owner` was passed and no person is signed in | Pass `{ owner: user }` to `documents.render` |
| `MAIN-PERM-0002` | The templates or fonts folder is not under `permissions.disk.read` | Add it in `main.yaml` |
| `MAIN-DOC-0011` | In `check`: a complex-script locale (Devanagari, Tamil, Arabic) on the built-in renderer | Use `gotenberg` or `docraptor` |

A missing translation in a template fails the [`i18n`](https://mainsdk.dev/docs/interfaces/i18n) check, not the reader.

## Swapping drivers

```yaml title="main.yaml"
documents:
  render: { driver: docraptor, key: secret:DOCRAPTOR_API_KEY }   # was builtin
```

Your `documents.render` calls stay the same. The HTML drivers render an HTML template (`templates/<name>.pdf.html`) after `sanitize`, and fetch nothing outside `network.allow`. See [Swap a driver](https://mainsdk.dev/docs/guides/swap-a-driver).

## Testing

The built-in renderer runs offline. Render, then read the result back with `documents.parse`:

```ts title="lib/invoice.test.ts"
import { it } from "node:test";
import assert from "node:assert/strict";
import { createTestApp } from "@mainsdk/testing";
import { documents } from "mainsdk";

it("documents AC11 renders an invoice and reads it back", async () => {
  createTestApp({ main: "./main.yaml" });
  const ref = await documents.render(
    { body: [{ text: "Invoice INV-7", style: "h1" }] },
    {},
    { owner: "u1" },
  );
  const doc = await documents.parse(ref, { as: "u1" });
  assert.match(doc.pages[0]?.text ?? "", /Invoice INV-7/);
});
```

## Security and privacy

- Generated documents often hold personal data. They are stored through [`files`](https://mainsdk.dev/docs/interfaces/files) with an owner, and you share them with expiring links.
- The HTML drivers receive the document data. Check their regions before you choose one; see [Data and regions](https://mainsdk.dev/docs/concepts/data-and-regions).
- Template values are sanitized before an HTML driver sees them.

## FAQ

**Do I need a hosted service?** No. The built-in renderer is the default.

**Which languages render?** The built-in renderer handles scripts that need no shaping, such as Latin, Greek, Cyrillic and CJK, with an embedded font that has the glyphs. For Devanagari, Tamil, Arabic and other scripts that need shaping, use `gotenberg` or `docraptor`.

## Next

- [Document reading](https://mainsdk.dev/docs/interfaces/documents)
- [Make, read and convert documents](https://mainsdk.dev/docs/guides/documents)
- [Languages and formats](https://mainsdk.dev/docs/interfaces/i18n)
- [Share links and QR codes](https://mainsdk.dev/docs/interfaces/share)
