Node.js & Bun
The Transactional client API is a single POST. Native fetch (Node 18+, Deno, Bun, browsers) is enough — no SDK to install.
Minimal example
const res = await fetch('https://api.transactional.dev/v1/generate', {
method: 'POST',
headers: {
'x-api-token': process.env.TRANSACTIONAL_API_TOKEN!,
'Content-Type': 'application/json',
},
body: JSON.stringify({
documentId: '1d8e5d56-1a4f-4b62-8c33-2d34a64b2f00',
variables: {
customer: {name: 'Acme Corp'},
invoice: {number: 'INV-2026-0142', total: 1280.50},
},
}),
})
if (!res.ok) {
const err = await res.json() as {error: string; message: string}
throw new Error(`Transactional ${res.status}: ${err.error} — ${err.message}`)
}
const {url} = await res.json() as {url: string; documentId: string}
console.log(url) // signed, short-lived
Production-grade wrapper
The endpoints are idempotent — the same documentId + variables always yields the same PDF (modulo upload timestamp in the URL). That makes retries safe. Two transient cases are worth handling:
- 503
storage_unavailable— S3 hiccup. The credit has already been refunded on our side. Retry once. - Network errors — TCP reset, DNS blip. Retry with backoff.
interface GenerateInput {
documentId: string
variables?: Record<string, unknown>
}
interface GenerateOk {
url: string
documentId: string
}
interface TransactionalError extends Error {
status: number
code: string
}
async function generatePdf(input: GenerateInput, opts: {maxRetries?: number} = {}): Promise<GenerateOk> {
const maxRetries = opts.maxRetries ?? 2
let lastErr: unknown
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const res = await fetch('https://api.transactional.dev/v1/generate', {
method: 'POST',
headers: {
'x-api-token': process.env.TRANSACTIONAL_API_TOKEN!,
'Content-Type': 'application/json',
},
body: JSON.stringify(input),
})
if (res.ok) return (await res.json()) as GenerateOk
const body = (await res.json().catch(() => ({}))) as {error?: string; message?: string}
const err = Object.assign(new Error(body.message ?? res.statusText), {
status: res.status,
code: body.error ?? 'unknown',
}) as TransactionalError
// 4xx (except 429) won't change — fail fast.
if (res.status >= 400 && res.status < 500 && res.status !== 429) throw err
// 429, 5xx — retry with exponential backoff.
lastErr = err
} catch (err) {
lastErr = err
}
if (attempt < maxRetries) await new Promise(r => setTimeout(r, 200 * 2 ** attempt))
}
throw lastErr
}
Branching on error codes
Branch on error (stable machine-readable code), never on message (localized via Accept-Language and may change wording).
try {
const {url} = await generatePdf({documentId, variables})
return url
} catch (err) {
const e = err as TransactionalError
switch (e.code) {
case 'quota_exceeded':
// 402 — out of credits. Surface to the user, link to billing.
throw new UserFacingError('Out of PDF credits this month.')
case 'NOT_FOUND':
case 'invalid_document_id':
// The documentId is wrong — programmer error, not a user issue.
throw new ProgrammerError(`Bad template: ${documentId}`)
case 'UNAUTHORIZED':
// Token revoked. Page oncall.
throw new ConfigError('TRANSACTIONAL_API_TOKEN is invalid.')
default:
throw err
}
}
Streaming the PDF to your storage
The returned url is signed and short-lived. Don't hand it to end users directly unless your UX is "click here, downloads immediately". For long-lived storage, pipe it through your own bucket:
import {createWriteStream} from 'node:fs'
import {pipeline} from 'node:stream/promises'
const {url} = await generatePdf({documentId, variables})
const pdfRes = await fetch(url)
if (!pdfRes.ok || !pdfRes.body) throw new Error(`PDF download failed: ${pdfRes.status}`)
await pipeline(pdfRes.body as any, createWriteStream(`/tmp/invoice-${Date.now()}.pdf`))
Or pipe directly to S3 with the AWS SDK:
import {S3Client, PutObjectCommand} from '@aws-sdk/client-s3'
const pdfRes = await fetch(url)
const buf = Buffer.from(await pdfRes.arrayBuffer())
await s3.send(new PutObjectCommand({
Bucket: 'invoices',
Key: `2026/05/${invoiceNumber}.pdf`,
Body: buf,
ContentType: 'application/pdf',
}))
Bun-specific note
Everything above runs on Bun without changes. If you want to skip the buffering step, Bun's Bun.write accepts a Response:
const pdfRes = await fetch(url)
await Bun.write('/tmp/invoice.pdf', pdfRes)
Next steps
- Storing & serving the generated PDF → — strategies for keeping PDFs around past the URL expiry.
- Monitoring usage & credits → — when to alert your team before you run out.