Getting started / TypeScript SDK
TypeScript SDK
pdfglyph is the official TypeScript/JavaScript client for the API. It wraps the one
endpoint you need — POST /v1/generate — and handles everything you
would otherwise hand-roll: typed errors you can branch on, automatic retries with backoff,
idempotent replays, and clean cancellation.
npm install pdfglyphIt has zero dependencies and is built on the platform fetch, so the same code runs on
Node 20+, edge runtimes (Vercel, Cloudflare Workers), Deno, and Bun. PDFs come
back as Uint8Array — never a Node Buffer — so nothing assumes a runtime.
Generate your first document
import { writeFile } from 'node:fs/promises';
import { PdfGlyph } from 'pdfglyph';
// One client per process — it is stateless and holds no connections.
const client = new PdfGlyph({ apiKey: process.env.PDFGLYPH_API_KEY! });
const { pdf, generationId, durationMs } = await client.generate({
templateId: 'tpl_invoice_8x2k',
data: { customer: 'Northwind Ltd', number: 'INV-2048', total: '€1,240.00' },
});
await writeFile('invoice.pdf', pdf); // pdf is a Uint8Array
console.log(`generated ${generationId} in ${durationMs}ms`);
generate resolves with the PDF bytes plus the generation metadata:
pdf— the document as aUint8Array, ready to save, return, or stream.generationId— the id of the logged generation, for correlating with your history.requestId— the request correlation id the API attaches to every response. Quote it to support if something looks wrong.durationMs— the server-side render duration.replayed—truewhen an idempotent replay served this call instead of a fresh render.
Client options
| Field | Type | Description |
|---|---|---|
| apiKey | stringrequired | Your API key (shown once at creation). Sent as the Authorization bearer header on every call. Keep it server-side. |
| baseUrl | stringoptional | API origin, without a trailing slash and without /v1. Defaults to the production API; override for tests. |
| maxRetries | numberoptional | Maximum automatic retries for transient failures, with exponential backoff and jitter. Defaults to 2; 0 disables retries. |
| fetch | functionoptional | Fetch implementation to use instead of the global one — for stubbing the network in tests or exotic runtimes. |
Handle errors by class
Every failure rejects with a PdfGlyphError subclass — one class per
error code, so you branch with instanceof instead of string-matching
statuses. Each error carries code, status, retryable, and requestId.
import {
PdfGlyphError,
InvalidDataError,
LimitExceededError,
RateLimitedError,
NetworkError,
} from 'pdfglyph';
try {
return await client.generate({ templateId, data });
} catch (err) {
if (err instanceof InvalidDataError) {
// 422 — the payload doesn't satisfy the template; the message names each field.
throw new UserFacingError(`Invoice data invalid: ${err.message}`);
}
if (err instanceof LimitExceededError) {
// 429, but NOT retryable — monthly quota. The SDK never retries this one.
notifyBilling('PDF quota reached — upgrade or wait for the next period');
}
if (err instanceof RateLimitedError) {
// Already retried with backoff before reaching you; err.retryAfter has the server's wait.
queue.requeue(job, { delaySeconds: err.retryAfter ?? 30 });
}
if (err instanceof NetworkError && err.retryable) {
// retryable is true only when the call carried an idempotencyKey — safe to re-run.
queue.requeue(job);
}
if (err instanceof PdfGlyphError) {
// Everything else: log the correlation id — support can find the request by it.
log.error({ code: err.code, requestId: err.requestId }, err.message);
}
throw err;
}
What gets retried, and when
The SDK retries transparently, up to maxRetries times, with exponential backoff and jitter:
rate_limited(429) andservice_unavailable(503) — always. A server-sentRetry-Afterheader is honoured over the computed backoff.- Network failures and gateway 502/503/504 (a response that is not the API's error
envelope — a proxy in front of the API during a deploy, an upstream timeout) — only when
the call carries an
idempotencyKey. A lost response can mask a request the API completed and billed; with a key, the replay returns the first outcome instead of billing twice, so the retry is safe. - Everything else — never. Quota, validation, and template errors fail the same way on every attempt; fix the cause instead.
error.retryable tells you whether the caller can safely retry after the SDK gave up.
Exactly-once generation
Pass an idempotencyKey to make a call safe to replay:
the API returns the first outcome and bills once. Derive the key from the business event,
not a random UUID, so redeliveries and job retries replay instead of regenerating:
// A webhook worker: one PDF per event, however many times the event is redelivered.
async function onInvoicePaid(event: WebhookEvent) {
const result = await client.generate({
templateId: 'tpl_invoice_8x2k',
data: mapInvoice(event),
idempotencyKey: `invoice-pdf:${event.id}`, // same event → same key → replay
});
if (result.replayed) {
// The original outcome, re-served: same generationId, no new quota spent.
console.log(`replayed ${result.generationId} for redelivered ${event.id}`);
}
await deliver(result.pdf);
}
Reusing a key with a different templateId or data is rejected with
IdempotencyConflictError (409) — a key maps to exactly one request. Keys expire after 24
hours.
Timeouts and cancellation
Pass any AbortSignal; AbortSignal.timeout gives you a deadline. The signal covers the
whole call, including retry backoff, and a caller-initiated abort always surfaces as-is
(an AbortError, a TimeoutError, or whatever custom reason you aborted with) — never
wrapped in a PdfGlyphError.
try {
const { pdf } = await client.generate({
templateId: 'tpl_invoice_8x2k',
data,
signal: AbortSignal.timeout(45_000),
});
} catch (err) {
if (err instanceof Error && err.name === 'TimeoutError') return renderFallback();
throw err;
}
The server gives a render up to 30 seconds before failing it, and the SDK may retry after that — so a deadline tighter than the render budget is a product decision, not a default. Pick a timeout that reflects what your caller can wait, and handle the abort.
Serving the PDF over HTTP
Uint8Array is a valid Response body, so route handlers pass it straight through:
// Next.js route handler (App Router)
export async function GET(_req: Request, { params }: { params: { id: string } }) {
const invoice = await getInvoice(params.id);
const { pdf } = await client.generate({ templateId: 'tpl_invoice_8x2k', data: invoice });
return new Response(pdf, {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `inline; filename="invoice-${invoice.number}.pdf"`,
},
});
}
In Node frameworks that expect a Buffer (Express and friends), wrap the bytes:
res
.set('Content-Type', 'application/pdf')
.set('Content-Disposition', 'attachment; filename="invoice.pdf"')
.send(Buffer.from(pdf));
The same client works unchanged in a Cloudflare Worker, Vercel Edge function, Deno, or Bun —
construct it with the key from your platform's secret binding and return the Response.
Many documents at once
Your plan bounds request throughput, and the SDK already retries
rate_limited honouring Retry-After — so a small worker pool is all a batch needs:
async function generateBatch(rows: InvoiceRow[], concurrency = 4) {
const results = new Array(rows.length);
let next = 0;
await Promise.all(
Array.from({ length: concurrency }, async () => {
for (let i = next++; i < rows.length; i = next++) {
results[i] = await client.generate({
templateId: 'tpl_invoice_8x2k',
data: rows[i],
idempotencyKey: `batch-2026-07:${rows[i].id}`, // re-running the batch is free
});
}
}),
);
return results;
}
Not on JavaScript?
The API is one POST with a JSON body — every language with an HTTP client integrates in a
few lines. See the cURL and Python tabs in the Quickstart, and the full
wire contract in Generate a document.