Skip to content

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.

Terminal window
pnpm add composepdf

Node 18+, Bun, Deno, Cloudflare Workers, or anywhere else fetch exists.

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.

Every published template declares what data it reads. composepdf types turns those declarations into one file:

Terminal window
pnpm exec composepdf types --out src/composepdf-types.ts

Pass 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 declares

Nothing 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.

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.

const { bytes, records } = await composepdf.render('cv_…', {
dataList: [invoiceA, invoiceB, invoiceC],
});

One document per record, collated in order. See Many documents, one file.

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 }.

await composepdf.render('cv_…', { data }, { idempotencyKey: `order-${orderId}` });

Presenting the same key again replays the stored PDF without rendering or billing a second time.

The client’s surface is exactly the published OpenAPI documentlistTemplates, render, renderAsync, getSchema, getSchemaSource, listRenders, getRender, downloadRender, getUsage and health. Anything you can reach with curl, you can reach here with types.