Storing & serving the generated PDF

POST /v1/generate returns a signed URL pointing at our CloudFront/S3 distribution. The URL is short-lived by design — it's not a permanent storage solution. This page is the playbook for keeping PDFs around past the URL's expiry.

Why the URL is temporary

The signed URL has two properties:

  • Time-limited — expires within the day, sometimes within the hour
  • Account-scoped — we serve only the buckets we control

That's deliberate. It limits exposure if a URL leaks, and it lets us evict old PDFs without breaking your integration. The contract is: /v1/generate is the source of truth; the URL is a one-shot delivery vehicle.

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.

Strategy 2 — Generate once, cache to your bucket

The standard pattern. Generate the PDF, immediately download and save it to your own S3 / Cloud Storage / Backblaze.

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(),
})

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), or your read traffic dwarfs your write traffic.

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.

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

Don't store the signed URL itself

This is the most common mistake. Storing the URL in your database means:

  • Tomorrow it 403s (signature expired)
  • Customer-facing emails point to broken URLs

If your code looks like invoice.pdfUrl = response.url, replace that pattern.

Store either:

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

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 URL expires before the user opens the inbox.

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

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

Next steps