TypeScript
composepdf is the typed client for this API. It carries no dependencies and no
layout engine — rendering stays server-side, so the PDF you get is the one your
designer previewed.
pnpm add composepdfnpm i composepdfyarn add composepdfbun add composepdfNode 18+, Bun, Deno, Cloudflare Workers, or anywhere else fetch exists.
Render
Section titled “Render”import { writeFile } from 'node:fs/promises';import { createClient } from 'composepdf';
const composepdf = createClient({ apiKey: process.env.COMPOSEPDF_API_KEY! });
const { bytes } = await composepdf.render('cv_…', { data: { customer: { name: 'Acme Inc.' }, items: [ { name: 'Design work', price: 1200 }, { name: 'Hosting', price: 90 }, ], },});
await writeFile('invoice.pdf', bytes);The key is a bearer credential — keep it server-side. For a preview inside your own product, use the embedded live preview, which takes a scoped embed token instead.
Generate types from your templates
Section titled “Generate types from your templates”Every published template declares what data it reads.
composepdf types turns those declarations into one file:
pnpm exec composepdf types --out src/composepdf-types.tsnpx composepdf types --out src/composepdf-types.tsyarn composepdf types --out src/composepdf-types.tsbunx composepdf types --out src/composepdf-types.tsPass the type map to the client, and both the template id and its payload are checked at compile time:
import { createClient } from 'composepdf';import type { Templates } from './composepdf-types';
const composepdf = createClient<Templates>({ apiKey: process.env.COMPOSEPDF_API_KEY! });
await composepdf.render('cv_…', { data: { customer: { name: 'Acme Inc.' } } });// ^ only ids this key can render// ^ only the fields this template declaresNothing here is hand-maintained. The interfaces are the templates’ own data contracts, so republishing a template with a new field and regenerating is the whole migration. Commit the generated file — it carries no timestamp, so an unchanged workspace regenerates to no diff.
The command reads COMPOSEPDF_API_KEY from the environment (--key overrides)
and writes composepdf-types.ts unless given --out.
Errors name the offending path
Section titled “Errors name the offending path”A payload the template cannot accept is rejected before the render starts, so a wrong call costs nothing and says exactly what was wrong:
import { ComposePdfError } from 'composepdf';
try { await composepdf.render('cv_…', { data });} catch (e) { if (e instanceof ComposePdfError && e.code === 'data_contract_violation') { for (const issue of e.issues) { console.error(issue.path, issue.message); // items[3].price expected number } }}ComposePdfError also carries status, requestId — quote it to support — and
retryAfter for 429 and 503.
Many records, one file
Section titled “Many records, one file”const { bytes, records } = await composepdf.render('cv_…', { dataList: [invoiceA, invoiceB, invoiceC],});One document per record, collated in order. See Many documents, one file.
Long renders
Section titled “Long renders”const job = await composepdf.renderAsync('cv_…', { dataList: manyRecords });
let render = await composepdf.getRender(job.id);while (!render.done) { await new Promise((r) => setTimeout(r, 1000)); render = await composepdf.getRender(job.id);}
const bytes = await composepdf.downloadRender(job.id);An accepted job always keeps its bytes. A synchronous render keeps them only
with options: { store: true }.
Retries that cannot double-charge
Section titled “Retries that cannot double-charge”await composepdf.render('cv_…', { data }, { idempotencyKey: `order-${orderId}` });Presenting the same key again replays the stored PDF without rendering or billing a second time.
What the client covers
Section titled “What the client covers”The client’s surface is exactly the published OpenAPI
document — listTemplates, render,
renderAsync, getSchema, getSchemaSource, listRenders, getRender,
downloadRender, getUsage and health. Anything you can reach with curl,
you can reach here with types.