Storing & serving the generated PDF

POST /v1/generate returns a URL pointing at our CloudFront/S3 distribution. That URL is public and permanent: it isn't signed, it doesn't expire, and the file stays served until something deletes it. This page is the playbook for deciding what "something" is.

What the URL actually is

Two properties matter, and they're the opposite of what a signed URL gives you:

  • Public — no signature, no token. Anyone who has the link can download the PDF. The path embeds your account id and the document uuid, so it isn't guessable in practice, but it also isn't protected. Treat it like an unlisted link, not a private one.
  • Permanent — there is no TTL. We don't evict old PDFs on your behalf.

The upside is that the URL keeps working: you can hand it straight to a browser, embed it, or store it. The downside is that retention is now your decision — a PDF you generated a year ago is still online unless you removed it.

So the contract is: /v1/generate creates a file, DELETE /v1/generations/file removes it, and nothing in between happens automatically.

Sensitive documents: generating a PDF creates a publicly readable object. If the document contains anything you wouldn't put behind an unlisted link — bank details, medical data, personal identifiers — download the bytes and delete our copy in the same job. See Strategy 2.

Deleting a generated PDF

Pass the url you got from /v1/generate back to the delete endpoint:

await fetch('https://api.transactional.dev/v1/generations/file', {
  method: 'DELETE',
  headers: {
    'x-api-token': process.env.TRANSACTIONAL_TOKEN!,
    'content-type': 'application/json',
  },
  body: JSON.stringify({path: url}),
})
// 204 — removed from storage and invalidated on the CDN

The details worth knowing:

  • No credit is consumed. Deleting is free.
  • The source document is untouched. You're deleting one rendered PDF, not the template. generate_pdf / /v1/generate will happily produce it again (at the cost of one credit).
  • You can only delete your own generated files. Anything else — another account's file, a document preview, a path that isn't a generation — returns 404, never 403.
  • A second delete returns 404. The effect is idempotent, the status code isn't. If you're cleaning up in a retry loop, treat 404 as success.
  • path accepts either the full URL or the bare storage path (client/42/generated/<uuid>/<ts>.pdf).

The three strategies

Pick the one that matches your retention needs.

Strategy 1 — Re-generate on demand

Don't store anything. Every time a user asks for the PDF, your backend calls /v1/generate again with the same documentId and variables.

app.get('/invoices/:id/pdf', async (req, res) => {
  const invoice = await Invoice.findById(req.params.id)
  const {url} = await generatePdf({
    documentId: process.env.INVOICE_TEMPLATE_UUID!,
    variables: invoice.toTemplateVariables(),
  })
  res.redirect(url)
})

When this works: PDFs are deterministic (same input = same output), and you have credit headroom. Low-traffic SaaS — pricing this against "5k generations / month" plan is fine if each customer looks at their invoice 1–2 times a month.

Tradeoff: every view costs a credit. Don't do this on a viral document.

Watch out: every call leaves a new file online. If you regenerate on each view and never delete, you accumulate one public PDF per view. Either delete the previous URL before regenerating, or use Strategy 2.

Strategy 2 — Generate once, cache to your bucket, delete ours

The recommended pattern. Generate the PDF, immediately download it to your own S3 / Cloud Storage / Backblaze, then delete our copy so it isn't left online.

import {S3Client, PutObjectCommand} from '@aws-sdk/client-s3'

const {url} = await generatePdf({documentId: TEMPLATE_UUID, variables})

const pdfRes = await fetch(url)
if (!pdfRes.ok) throw new Error(`download failed: ${pdfRes.status}`)
const buf = Buffer.from(await pdfRes.arrayBuffer())

await s3.send(new PutObjectCommand({
  Bucket: 'invoices.acme.example',
  Key: `2026/05/${invoice.number}.pdf`,
  Body: buf,
  ContentType: 'application/pdf',
}))

await db.update(Invoice, invoice.id, {
  pdfKey: `2026/05/${invoice.number}.pdf`,
  generatedAt: new Date(),
})

// The bytes are safe in your bucket — drop our public copy.
await fetch('https://api.transactional.dev/v1/generations/file', {
  method: 'DELETE',
  headers: {
    'x-api-token': process.env.TRANSACTIONAL_TOKEN!,
    'content-type': 'application/json',
  },
  body: JSON.stringify({path: url}),
})

Serving is then your responsibility — either a signed URL from your bucket, or a streaming endpoint:

app.get('/invoices/:id/pdf', async (req, res) => {
  const invoice = await Invoice.findById(req.params.id)
  const obj = await s3.send(new GetObjectCommand({
    Bucket: 'invoices.acme.example',
    Key: invoice.pdfKey,
  }))
  res.setHeader('Content-Type', 'application/pdf')
  obj.Body.pipe(res)
})

When this works: legal/compliance requires a stable archive (you need to retain invoices for 7 years), your read traffic dwarfs your write traffic, or the PDF holds anything sensitive. This is the only strategy where the document never sits on a public URL for longer than the round-trip.

Strategy 3 — Stream-through proxy

Don't store, don't re-generate. Stream the PDF through your backend the first time, set a cache header.

app.get('/invoices/:id/pdf', async (req, res) => {
  const invoice = await Invoice.findById(req.params.id)
  const {url} = await generatePdf({documentId: TEMPLATE_UUID, variables: invoice.toVars()})

  const pdfRes = await fetch(url)
  res.setHeader('Content-Type', 'application/pdf')
  res.setHeader('Content-Disposition', `attachment; filename="invoice-${invoice.number}.pdf"`)
  res.setHeader('Cache-Control', 'private, max-age=3600')
  pdfRes.body.pipe(res)
})

A CDN in front (Cloudflare, Fastly) caches by URL. Hit rate solves the "every view costs a credit" problem.

Same caveat as Strategy 1: the file you streamed is still online afterwards. Fire the delete once the stream finishes if you don't want it to linger.

When this works: simple stack, no S3 dependency, the PDF is only meaningful to one user (no shared link).

Think twice before storing the URL as your only record

The URL keeps working, so storing it isn't broken the way an expiring URL would be. But making invoice.pdfUrl = response.url your source of truth has two real costs:

  • You've persisted a public link. Anything that reads that column — a log, an export, an admin UI, a leaked backup — hands out the PDF.
  • You can't reconstruct it. If the row is lost, the file is still online and you no longer have the path to delete it. Orphaned public PDFs are the failure mode here, not broken links.

Prefer to store either:

  • The PDF bytes in your bucket, and delete ours (Strategy 2)
  • Or just the invoice id + template UUID + variables so you can regenerate on demand (Strategy 1)

If you do keep the URL, keep it precisely so you can pass it to DELETE /v1/generations/file later — treat it as a handle for cleanup, not as a public field.

Mailable attachments

PDFs in emails are downloaded once and live in the recipient's mailbox. Generate inline:

// Pseudo-mailer
const {url} = await generatePdf({documentId, variables})
const pdf = await fetch(url).then(r => r.arrayBuffer())

await mailer.send({
  to: customer.email,
  subject: `Invoice ${invoice.number}`,
  body: `Your invoice is attached.`,
  attachments: [
    {filename: `invoice-${invoice.number}.pdf`, content: Buffer.from(pdf)},
  ],
})

Don't link to /v1/generate URLs in emails. The link won't break — that's the problem. It's a public URL that ends up forwarded, archived on mail servers, and scanned by link previewers, for as long as the file exists. Attach the bytes and delete our copy.

What to do when /v1/generate is slow

Render time is usually 200–600 ms depending on template complexity (number of fonts, chart count, page count). If it's a hot path:

  • Pre-generate on creation, not on view. When the invoice is finalized, queue a job that calls /v1/generate and stashes the bytes (Strategy 2). Reads then never wait.
  • Queue the call with BullMQ / Celery / Sidekiq. Don't block the HTTP request.
  • Cache aggressively if the same documentId + variables is hit by multiple users (rare for transactional, common for marketing one-pagers).

Long-term storage compliance

For regulated industries (finance, healthcare):

  • Strategy 2 is the only one that makes the auditor happy — and the delete call is what closes it. A PDF left on a public URL is not an access-controlled archive.
  • Encrypt at rest (S3 SSE-KMS or your bucket's equivalent).
  • Set lifecycle policies (e.g. Glacier after 90 days, delete after 7 years).
  • Log every read to your own access log.
  • For erasure requests (GDPR art. 17 and equivalents), DELETE /v1/generations/file is how you remove our copy. Keep enough of a record to know which URLs belong to a given subject.

We don't currently offer a "long-term archive" tier — that's by design. Your bucket, your compliance boundary.

Next steps