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