Docs

Three ways in: a 30-second quickstart, the live REST reference, or the typed TypeScript SDK.

Quickstart

Get an API key from app.paperjet.dev, then render your first PDF.

  1. 1. Render a PDF with curl

    curl -X POST https://api.paperjet.dev/v1/render \
      -H "Authorization: Bearer $PAPERJET_API_KEY" \
      -H "Content-Type: application/json" \
      -o hello.pdf \
      -d '{"source":"= Hello #data.name","data":{"name":"World"}}'
    
    # 200 OK · application/pdf · 11 KB.
  2. 2. Inject JSON data into Typst

    Whatever you pass under data shows up inside the template as a top-level Typst binding called data. It's a real Typst dictionary — use .foo dot access, arrays with .at(0), branching with if, mapping with .map().

    // source field of the request body, or stored as a template:
    = Hello #data.user.name
    
    // loops + branching are real:
    #for item in data.cart [
      - #item.title (#item.qty × #item.price €)
    ]
  3. 3. Reuse a template across calls

    Upload your Typst source once, then reference it by id on every render. Fresh JSON data each time — same template. Available on every plan.

    # 1. Upload the template once (or use the dashboard /templates page)
    curl -X POST https://api.paperjet.dev/v1/templates \
      -H "Authorization: Bearer $KEY" \
      -H "Content-Type: application/json" \
      -d '{"id":"invoice-v1","source":"= Invoice #data.invoice.number"}'
    
    # 2. Render against it as often as you want
    curl -X POST https://api.paperjet.dev/v1/render \
      -H "Authorization: Bearer $KEY" \
      -d '{"template_id":"invoice-v1","data":{...}}'
    # Templates: max 500 KB, slug `[A-Za-z0-9_-]{1,60}`. Re-POSTing replaces the source.
  4. 4. Make retries safe

    Pass Idempotency-Key on render requests. A retry within 24 h with the same key + same body replays the cached PDF — no double render, no double bill. Available on every plan.

    curl -X POST https://api.paperjet.dev/v1/render \
      -H "Authorization: Bearer $KEY" \
      -H "Idempotency-Key: order-12345" \
      -d '{"template_id":"invoice-v1","data":{...}}'

Sample templates

Three starter Typst templates to adapt and test with your own data. Copy a source block, POST it to /v1/templates, then render with the matching data shape.

Invoice — line items + totals

Tabular invoice with auto-summed totals and tax. Expects data.invoice, data.from, data.to, data.items[].

invoice.typ
#set page(margin: 2cm)
#set text(font: "Inter", size: 10pt)

#text(size: 22pt, weight: "bold")[
  Invoice #data.invoice.number
]

#grid(
  columns: (1fr, 1fr),
  gutter: 2em,
  [*From* \\ #data.from.name \\ #data.from.address],
  [*Bill to* \\ #data.to.name \\ #data.to.address],
)

#v(1em)
#table(
  columns: (1fr, auto, auto, auto),
  align: (left, right, right, right),
  [*Description*], [*Qty*], [*Unit*], [*Total*],
  ..data.items.map(it => (
    [#it.description],
    [#it.quantity],
    [#data.currency#it.unit_price],
    [#data.currency#calc.round(it.quantity * it.unit_price, digits: 2)],
  )).flatten()
)

#let subtotal = data.items.fold(0, (s, it) => s + it.quantity * it.unit_price)
#let tax = subtotal * data.invoice.tax_rate
#let total = subtotal + tax

#align(right)[
  *Subtotal:* #data.currency#subtotal \\
  *Tax (#int(data.invoice.tax_rate * 100)%):* #data.currency#tax \\
  *Total:* #data.currency#total
]
data shape
{
  "invoice": {
    "number": "INV-2026-0042",
    "tax_rate": 0.20
  },
  "currency": "€",
  "from": {
    "name": "PaperJet",
    "address": "France"
  },
  "to": {
    "name": "Acme Corp",
    "address": "123 Main St, NYC"
  },
  "items": [
    {
      "description": "Monthly subscription",
      "quantity": 1,
      "unit_price": 29
    },
    {
      "description": "Overage 100 PDFs",
      "quantity": 100,
      "unit_price": 0.005
    }
  ]
}

Receipt — single transaction

Compact receipt for confirmation emails. Single page, narrow layout, no logo dependency.

#set page(width: 10cm, height: auto, margin: 1cm)
#set text(font: "Inter", size: 9pt)

#align(center)[
  #text(size: 14pt, weight: "bold")[Receipt]
  \\
  #data.merchant.name
  \\
  #text(fill: gray)[#data.transaction.id]
]

#v(0.5em)
#line(length: 100%, stroke: 0.5pt + gray)

*Date:* #data.transaction.date \\
*Method:* #data.transaction.payment_method

#v(0.5em)
#for item in data.items [
  #item.name #h(1fr) #data.currency#item.amount \\
]

#line(length: 100%, stroke: 0.5pt + gray)
*Total* #h(1fr) *#data.currency#data.transaction.total*

Contract — signature blocks

Multi-page agreement template with parties, body paragraphs from data, and dual signature lines. Page breaks behave automatically — no widow/orphan hacks.

#set page(margin: 2.5cm, numbering: "1 / 1")
#set text(font: "Inter", size: 11pt)
#set par(justify: true, leading: 0.65em)

#align(center)[
  #text(size: 18pt, weight: "bold")[#data.title]
  \\
  #text(fill: gray, size: 10pt)[Effective #data.effective_date]
]

#v(2em)
*Between* #data.party_a.name (#data.party_a.address)
*and* #data.party_b.name (#data.party_b.address).

#for (i, clause) in data.clauses.enumerate() [
  == #int(i + 1). #clause.title

  #clause.body
]

#v(3em)
#grid(columns: (1fr, 1fr), gutter: 2em,
  [*#data.party_a.name* \\ #line(length: 100%, stroke: 0.5pt) \\ Date: #line(length: 60%, stroke: 0.5pt)],
  [*#data.party_b.name* \\ #line(length: 100%, stroke: 0.5pt) \\ Date: #line(length: 60%, stroke: 0.5pt)]
)

All three render as plain PDFs by default. Add "options": {"ua_compliant": true} to request tagged PDF output. Validate representative documents yourself before making accessibility or compliance claims. See the tagged PDF authoring notes below for the extra discipline that needs.

TypeScript SDK

@paperjet/sdk wraps fetch with auth, structured errors, and typed responses. Works in Node 18+, Deno, and Cloudflare Workers.

// npm install @paperjet/sdk

import { Paperjet, PaperjetError } from "@paperjet/sdk";
import { writeFile } from "node:fs/promises";

const pj = new Paperjet({ apiKey: process.env.PAPERJET_API_KEY! });

try {
  const pdf = await pj.render(
    { template_id: "invoice-v1", data: invoice },
    { idempotencyKey: `order-$+invoice.id`} },
  );
  await writeFile("out.pdf", pdf);
} catch (err) {
  if (err instanceof PaperjetError) {
    console.error(err.code, err.message, err.requestId);
  }
}

The full method surface — pj.renders.list, pj.templates.list, pj.webhooks, pj.audit.list — is in the REST reference. API-key management stays in the dashboard so leaked API keys cannot mint more keys.

History & audit

Render history stores metadata — status, duration, byte size, template id, API key id, request id, and failure diagnostics. Filter server-side so dashboards and support tools do not accidentally search only the first page.

# Failed renders from the last 24 hours, newest first.
SINCE=$(( $(date +%s) - 86400 ))
curl "https://api.paperjet.dev/v1/renders?status=failed&since=$SINCE" \
  -H "authorization: Bearer $PAPERJET_API_KEY"

# Audit trail: exact event or trailing-* prefix filters.
curl https://api.paperjet.dev/v1/audit?event=template.* \
  -H "authorization: Bearer $PAPERJET_API_KEY"

Use request_id, error_code, and error_message from failed render rows when debugging a Typst error or contacting support. Both endpoints are available on every plan.

Webhooks

Register an HTTPS endpoint and we'll POST a signed event for each render outcome. Two events today: render.completed and render.failed. Available on every plan.

# Register an endpoint — the signing secret is shown ONCE.
curl -X POST https://api.paperjet.dev/v1/webhooks \
  -H "authorization: Bearer $PAPERJET_API_KEY" \
  -H "content-type: application/json" \
  -d '{"url": "https://your-app.example.com/webhooks/paperjet"}'
# { "id": "...", "secret": "whsec_...", "secret_prefix": "whsec_abc123", ... }

Each delivery carries X-Paperjet-Signature: t=<unix>,v1=<hex>. Verify with HMAC_SHA256(secret, t + "." + raw_body) and reject if t is older than 5 minutes (replay protection). Permanent 4xx responses are not retried for that delivery; endpoints are auto-disabled after 10 consecutive failed deliveries.

Tagged PDF authoring

options.ua_compliant: true asks Typst for tagged PDF output. This is intended to help with PDF/UA-style accessibility workflows, but it is not a legal or certification guarantee.

The compute side handles structure tree, MCID tagging, and role mapping, but a few authoring rules in your template have to hold or the render returns 422 compilation_failed. Render history keeps the sanitized diagnostic in error_message. Validate generated documents with your own accessibility tooling before shipping them to users.

1. Set the document language

Top of every tagged/accessibility-oriented template:

#set text(lang: "en")
// or "fr", "de", etc.

Without this, screen readers don't know how to pronounce the content.

2. Title every image

Every image() needs an alt:

#image("logo.png", alt: "Acme logo")

Decorative images: pass alt: "" explicitly so the renderer knows it should be silent (not "missing alt").

3. Use real headings

= H1, == H2, === H3. Don't fake them with #text(size: 18pt, weight: "bold") — screen readers won't see structure.

Heading levels must not skip (no H1 → H3 jump). Typst's outline emits a structure tree from these directly.

4. Tables get headers

The first row of a #table is assumed to be the header row. Wrap header cells in table.header(...) if your layout has multiple header rows or skipped first row.

#table(
  columns: 3,
  table.header[*Name*][*Qty*][*Price*],
  ..rows
)

If you get 422 compilation_failed

The render-history diagnostic names the missing piece — common ones:

  • image without alt text — add alt: "..." on every image()
  • document language is not set — add #set text(lang: "...") at the top
  • untagged content — usually a raw place() call without semantic context; wrap it in a heading or paragraph

Validate locally before shipping with pdfua-validator or veraPDF (open-source). Do not publish accessibility claims until representative outputs pass your validation suite.

Looking for something specific?

The interactive API reference covers every endpoint, request/response shape, and error code.

Open the REST reference →