If you are building a SaaS product, PDF generation requirements will show up sooner or later. Tenants want to download invoices, export monthly reports, generate contracts, and produce certificates — none of which are worth running your own PDF rendering engine for.
This guide covers the architecture decisions involved in integrating a PDF generation API into a multi-tenant SaaS product. Rather than just showing how to call an API, it addresses the full scope: tenant isolation, template management, async processing, webhook delivery, white-labeling, rate limiting, and cost control.
Examples use FUNBREW PDF, but the patterns apply to any PDF API. For basic API calls, see the quickstart by language. For production stability concerns, see the PDF API production checklist.
When SaaS Products Need PDF Generation
PDF output requirements in SaaS products fall into five categories:
| Use case | Examples | Typical volume | Recommended pattern |
|---|---|---|---|
| Invoices and receipts | Automatic issuance at billing time | Hundreds to tens of thousands per month | Async batch |
| Monthly and weekly reports | PDF export of dashboard data | On-demand | Synchronous |
| Contracts and agreements | Auto-generation at contract signing | On-demand | Synchronous |
| Certificates and credentials | Issued on course completion | Event-driven | Async |
| Forms and slips | Purchase orders, delivery notes | Monthly batch | Async batch |
Each category has different requirements for response time, volume, and tenant isolation. Clarify which scenario you are solving before choosing an architecture.
Architecture Patterns: Sync, Async, and Batch
Pattern 1: Synchronous (user wants the PDF right now)
The simplest pattern: generate the PDF when the user clicks "Download" and return it immediately.
Browser → App server → PDF API → App server → Browser (PDF response)
// SaaS backend: synchronous PDF endpoint
import express from 'express';
const router = express.Router();
router.post('/invoices/:id/download', async (req, res) => {
const invoice = await Invoice.findByTenantAndId(
req.tenant.id, // multi-tenant: scope by tenant ID
req.params.id
);
if (!invoice) {
return res.status(404).json({ error: 'Invoice not found' });
}
const html = await renderInvoiceTemplate(invoice, req.tenant);
const pdfResponse = await fetch('https://pdf.funbrew.cloud/api/v1/pdf/generate', {
method: 'POST',
headers: {
'X-API-Key': process.env.FUNBREW_PDF_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
html,
options: {
format: 'A4',
margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' },
},
}),
signal: AbortSignal.timeout(30_000), // 30-second timeout
});
if (!pdfResponse.ok) {
throw new Error(`PDF generation failed: HTTP ${pdfResponse.status}`);
}
const pdfBuffer = Buffer.from(await pdfResponse.arrayBuffer());
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="invoice-${invoice.number}.pdf"`);
res.send(pdfBuffer);
});
Use synchronous generation when response time stays under 20 seconds. For heavier PDFs or higher volumes, move to the next pattern.
Pattern 2: Asynchronous (job queue)
For heavy PDFs, large volumes, or situations where users do not need the file instantly, use a job queue. Return a job ID immediately, then notify via webhook or email when the PDF is ready.
Browser → App server (returns job ID)
↓ (enqueue job)
Worker → PDF API → S3 upload
↓
Webhook → App server → Notify browser
// BullMQ job queue for async PDF generation
import { Queue, Worker } from 'bullmq';
import { Redis } from 'ioredis';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
const connection = new Redis(process.env.REDIS_URL);
const pdfQueue = new Queue('saas-pdf', { connection });
const s3 = new S3Client({ region: 'us-east-1' });
// Enqueue endpoint: returns job ID immediately
export async function enqueuePdfJob(tenantId, jobType, payload) {
const job = await pdfQueue.add(jobType, {
tenantId,
...payload,
}, {
attempts: 3,
backoff: { type: 'exponential', delay: 2000 },
// Priority per tenant (paid plans get higher priority)
priority: payload.priority ?? 0,
});
return { jobId: job.id, status: 'queued' };
}
// Worker: generates PDF and stores in S3
const worker = new Worker('saas-pdf', async (job) => {
const { tenantId, invoiceId } = job.data;
const tenant = await Tenant.findById(tenantId);
const invoice = await Invoice.findById(invoiceId);
const html = await renderInvoiceTemplate(invoice, tenant);
const response = await fetch('https://pdf.funbrew.cloud/api/v1/pdf/generate', {
method: 'POST',
headers: {
'X-API-Key': process.env.FUNBREW_PDF_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ html, options: { format: 'A4' } }),
signal: AbortSignal.timeout(120_000),
});
if (!response.ok) throw new Error(`PDF API error: ${response.status}`);
const pdfBuffer = Buffer.from(await response.arrayBuffer());
// Store in S3, isolated by tenant ID
const key = `tenants/${tenantId}/invoices/${invoiceId}.pdf`;
await s3.send(new PutObjectCommand({
Bucket: process.env.PDF_STORAGE_BUCKET,
Key: key,
Body: pdfBuffer,
ContentType: 'application/pdf',
ServerSideEncryption: 'AES256',
}));
await PdfJob.update(job.id, {
status: 'completed',
s3Key: key,
completedAt: new Date(),
});
return { s3Key: key };
}, { connection, concurrency: 5 });
worker.on('failed', (job, err) => {
console.error(`PDF job ${job?.id} failed:`, err.message);
});
Pattern 3: Batch processing (monthly bulk generation)
For generating all tenant invoices at month-end, batch processing is the right choice. The batch processing guide covers the mechanics; here is the SaaS-specific layer.
# Python: monthly invoice batch across all tenants
import asyncio
import aiohttp
import os
from typing import List
async def generate_monthly_invoices(tenants: List[dict], year: int, month: int):
"""Generate monthly invoices for all tenants concurrently."""
semaphore = asyncio.Semaphore(10) # limit concurrent API calls
async def generate_for_tenant(session, tenant):
async with semaphore:
invoices = await get_monthly_invoices(tenant['id'], year, month)
if not invoices:
return {'tenantId': tenant['id'], 'count': 0}
items = [
{
'html': await render_invoice_html(invoice, tenant),
'filename': f"invoice-{invoice['number']}.pdf",
'options': {'format': 'A4'},
}
for invoice in invoices
]
async with session.post(
'https://pdf.funbrew.cloud/api/v1/pdf/batch',
headers={'X-API-Key': os.environ['FUNBREW_PDF_API_KEY']},
json={'items': items},
timeout=aiohttp.ClientTimeout(total=300),
) as resp:
resp.raise_for_status()
result = await resp.json()
for item_result in result['data']['results']:
await store_pdf_result(tenant['id'], item_result)
return {'tenantId': tenant['id'], 'count': len(items)}
async with aiohttp.ClientSession() as session:
tasks = [generate_for_tenant(session, tenant) for tenant in tenants]
results = await asyncio.gather(*tasks, return_exceptions=True)
failures = [r for r in results if isinstance(r, Exception)]
if failures:
await notify_batch_failures(failures)
return results
Multi-Tenant Template Management
The core of multi-tenancy is isolation. PDF generation is no exception — each tenant needs its own templates, branding, and configuration.
Template data structure
-- Per-tenant PDF template table
CREATE TABLE pdf_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
slug VARCHAR(100) NOT NULL, -- 'invoice', 'report', 'contract'
name VARCHAR(255) NOT NULL,
html TEXT NOT NULL, -- Handlebars template
css TEXT,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE (tenant_id, slug)
);
-- Default templates (fallback when tenant has no custom template)
CREATE TABLE default_pdf_templates (
slug VARCHAR(100) PRIMARY KEY,
html TEXT NOT NULL,
css TEXT
);
Fallback resolution
// Resolve template: tenant-specific first, then default
async function resolveTemplate(tenantId: string, slug: string): Promise<Template> {
const tenantTemplate = await db.query<Template>(
'SELECT * FROM pdf_templates WHERE tenant_id = $1 AND slug = $2 AND is_active = true',
[tenantId, slug]
);
if (tenantTemplate.rows.length > 0) {
return tenantTemplate.rows[0];
}
const defaultTemplate = await db.query<Template>(
'SELECT * FROM default_pdf_templates WHERE slug = $1',
[slug]
);
if (defaultTemplate.rows.length === 0) {
throw new Error(`Template not found: ${slug}`);
}
return defaultTemplate.rows[0];
}
// Render template with tenant brand data injected
async function renderTemplate(
tenantId: string,
slug: string,
variables: Record<string, unknown>
): Promise<string> {
const template = await resolveTemplate(tenantId, slug);
const tenant = await Tenant.findById(tenantId);
const enrichedVars = {
...variables,
brand: {
name: tenant.companyName,
logo_url: tenant.logoUrl,
primary_color: tenant.brandColor ?? '#2563EB',
address: tenant.address,
},
};
return Handlebars.compile(template.html)(enrichedVars);
}
Template management API for tenants
// Allow tenant admins to update their own templates
router.put('/templates/:slug', requireTenantAdmin, async (req, res) => {
const { html, css, name } = req.body;
const sanitizedHtml = sanitizeTemplateHtml(html);
// Validate by generating a preview PDF
try {
await generatePreviewPdf(sanitizedHtml, css);
} catch (err) {
return res.status(422).json({
error: 'Template rendering failed',
details: err.message,
});
}
await db.query(
`INSERT INTO pdf_templates (tenant_id, slug, name, html, css)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (tenant_id, slug) DO UPDATE
SET name = EXCLUDED.name,
html = EXCLUDED.html,
css = EXCLUDED.css,
updated_at = NOW()`,
[req.tenant.id, req.params.slug, name, sanitizedHtml, css]
);
res.json({ success: true });
});
Template Engine Integration
Use a proper template engine rather than string concatenation to support loops, conditionals, and formatting in your PDF templates.
Handlebars invoice template
<!-- templates/invoice.hbs -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<style>
body { font-family: 'Inter', sans-serif; margin: 0; }
.header { display: flex; justify-content: space-between; padding: 40px; }
.logo { height: 48px; }
.invoice-meta { text-align: right; color: #6B7280; font-size: 14px; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 12px 16px; text-align: left; border-bottom: 1px solid #E5E7EB; }
th { background: #F9FAFB; font-weight: 600; }
.total-row { font-size: 18px; font-weight: 700; }
{{#if brand.primary_color}}
.accent { color: {{brand.primary_color}}; }
.header-bar { background: {{brand.primary_color}}; height: 4px; }
{{/if}}
</style>
</head>
<body>
<div class="header-bar"></div>
<div class="header">
{{#if brand.logo_url}}
<img src="{{brand.logo_url}}" alt="{{brand.name}}" class="logo">
{{else}}
<h1 class="accent">{{brand.name}}</h1>
{{/if}}
<div class="invoice-meta">
<p><strong>Invoice:</strong> {{invoice.number}}</p>
<p><strong>Date:</strong> {{formatDate invoice.issued_at}}</p>
<p><strong>Due:</strong> {{formatDate invoice.due_at}}</p>
</div>
</div>
<div style="padding: 0 40px;">
<p><strong>Bill to: {{customer.name}}</strong></p>
<p style="color: #6B7280; font-size: 14px;">{{customer.address}}</p>
</div>
<div style="padding: 24px 40px;">
<table>
<thead>
<tr>
<th>Description</th>
<th style="text-align: right;">Qty</th>
<th style="text-align: right;">Unit price</th>
<th style="text-align: right;">Amount</th>
</tr>
</thead>
<tbody>
{{#each invoice.line_items}}
<tr>
<td>{{this.description}}</td>
<td style="text-align: right;">{{this.quantity}}</td>
<td style="text-align: right;">{{formatCurrency this.unit_price}}</td>
<td style="text-align: right;">{{formatCurrency this.amount}}</td>
</tr>
{{/each}}
</tbody>
<tfoot>
<tr><td colspan="3" style="text-align: right;">Subtotal</td><td style="text-align: right;">{{formatCurrency invoice.subtotal}}</td></tr>
<tr><td colspan="3" style="text-align: right;">Tax ({{invoice.tax_rate}}%)</td><td style="text-align: right;">{{formatCurrency invoice.tax_amount}}</td></tr>
<tr class="total-row">
<td colspan="3" style="text-align: right;" class="accent">Total due</td>
<td style="text-align: right;" class="accent">{{formatCurrency invoice.total_amount}}</td>
</tr>
</tfoot>
</table>
</div>
{{#if invoice.notes}}
<div style="padding: 24px 40px; color: #6B7280; font-size: 13px;">
<p><strong>Notes:</strong> {{invoice.notes}}</p>
</div>
{{/if}}
<div style="padding: 24px 40px; border-top: 1px solid #E5E7EB; font-size: 12px; color: #9CA3AF;">
<p>{{brand.name}} — {{brand.address}}</p>
</div>
</body>
</html>
// Register Handlebars helpers
const Handlebars = require('handlebars');
Handlebars.registerHelper('formatDate', (date) => {
return new Intl.DateTimeFormat('en-US', {
year: 'numeric', month: 'long', day: 'numeric',
}).format(new Date(date));
});
Handlebars.registerHelper('formatCurrency', (amount) => {
return new Intl.NumberFormat('en-US', {
style: 'currency', currency: 'USD',
}).format(amount);
});
For loops, conditionals, and nested data patterns in templates, see the PDF template engine guide.
Webhook Delivery
After async PDF generation, tenants need to know when their file is ready. Two patterns are common in SaaS.
Pattern A: Internal webhook (worker to app server)
// Worker notifies the app server after PDF is stored
async function notifyPdfCompleted(jobId: string, result: PdfResult) {
await fetch(`${process.env.APP_INTERNAL_URL}/webhooks/pdf-completed`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Internal-Secret': process.env.INTERNAL_WEBHOOK_SECRET,
},
body: JSON.stringify({
jobId,
tenantId: result.tenantId,
s3Key: result.s3Key,
completedAt: new Date().toISOString(),
}),
});
}
// App server receives the webhook and notifies the user
router.post('/webhooks/pdf-completed', verifyInternalSecret, async (req, res) => {
const { jobId, tenantId, s3Key } = req.body;
await PdfJob.markCompleted(jobId, s3Key);
const job = await PdfJob.findById(jobId);
await notifyUser(job.userId, {
type: 'pdf_ready',
downloadUrl: generateSignedUrl(s3Key, { expiresIn: '24h' }),
});
res.json({ ok: true });
});
Pattern B: Forwarding webhooks to tenants
For enterprise tenants that want events delivered to their own systems:
interface TenantWebhookConfig {
tenantId: string;
url: string;
secret: string; // for HMAC signature verification
events: ('pdf.completed' | 'pdf.failed' | 'batch.completed')[];
isActive: boolean;
}
async function forwardWebhookToTenant(
config: TenantWebhookConfig,
event: string,
payload: object
) {
const body = JSON.stringify({ event, data: payload, timestamp: Date.now() });
const signature = createHmacSignature(body, config.secret);
try {
const response = await fetch(config.url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Webhook-Signature': `sha256=${signature}`,
'X-Webhook-Event': event,
},
body,
signal: AbortSignal.timeout(10_000),
});
await WebhookDelivery.record({
tenantId: config.tenantId,
event,
statusCode: response.status,
success: response.ok,
});
} catch (err) {
await WebhookDelivery.recordFailure(config.tenantId, event, err.message);
await scheduleWebhookRetry(config, event, payload);
}
}
function createHmacSignature(body: string, secret: string): string {
return require('crypto')
.createHmac('sha256', secret)
.update(body)
.digest('hex');
}
For more webhook patterns, see the PDF API webhook integration guide.
Rate Limiting and Queue Design
Multiple tenants generating PDFs simultaneously means you need to control throughput at the application layer before hitting the external API's rate limits.
Fair resource distribution per tenant
// Concurrency limits per plan
const PLAN_CONCURRENCY = {
free: 1,
starter: 3,
professional: 10,
enterprise: 30,
} as const;
// Enqueue with per-tenant concurrency cap
async function enqueueWithTenantLimit(
tenantId: string,
plan: keyof typeof PLAN_CONCURRENCY,
jobData: object
) {
const queue = new Queue('pdf-generation', { connection });
await queue.add('generate', jobData, {
group: {
id: tenantId,
limit: {
max: PLAN_CONCURRENCY[plan],
duration: 60_000,
},
},
attempts: 3,
backoff: { type: 'exponential', delay: 2000 },
});
}
Global rate limiter with Redis
import redis
import time
class GlobalRateLimiter:
"""Sliding window rate limiter backed by Redis."""
def __init__(self, redis_client, max_requests: int, window_seconds: int):
self.redis = redis_client
self.max_requests = max_requests
self.window = window_seconds
def acquire(self, timeout_seconds: float = 5.0) -> bool:
"""Try to acquire a request slot. Returns False on timeout."""
key = 'global:pdf_api:rate_limit'
deadline = time.time() + timeout_seconds
while time.time() < deadline:
now_ms = int(time.time() * 1000)
window_start = now_ms - self.window * 1000
pipe = self.redis.pipeline()
pipe.zremrangebyscore(key, '-inf', window_start)
pipe.zadd(key, {str(now_ms): now_ms})
pipe.zcard(key)
pipe.expire(key, self.window + 1)
_, _, count, _ = pipe.execute()
if count <= self.max_requests:
return True
time.sleep(0.1)
return False
# Operate at 80% of the API limit as a safety buffer
limiter = GlobalRateLimiter(
redis_client=redis.Redis.from_url(os.environ['REDIS_URL']),
max_requests=80,
window_seconds=60,
)
async def generate_pdf_with_rate_limit(html: str, tenant_id: str):
if not limiter.acquire(timeout_seconds=10):
raise TooManyRequestsError('PDF generation rate limit exceeded')
return await call_pdf_api(html)
Queue depth monitoring
async function getPdfQueueHealth() {
const queue = new Queue('pdf-generation', { connection });
const [waiting, active, failed, delayed] = await Promise.all([
queue.getWaitingCount(),
queue.getActiveCount(),
queue.getFailedCount(),
queue.getDelayedCount(),
]);
return {
waiting,
active,
failed,
delayed,
isHealthy: waiting < 500 && failed < 50,
};
}
White-Labeling: Dynamic Brand Injection
Enterprise SaaS customers expect PDFs to carry their own brand, not yours.
Brand configuration structure
interface TenantBrandConfig {
tenantId: string;
companyName: string;
logoUrl: string | null;
primaryColor: string; // '#2563EB'
secondaryColor: string;
fontFamily: string; // 'Inter', 'Roboto', etc.
footerText: string;
watermark: {
enabled: boolean;
text: string; // 'CONFIDENTIAL', 'DRAFT', etc.
opacity: number; // 0.1 to 0.3
};
}
Dynamic brand CSS generation
function generateBrandCss(brand: TenantBrandConfig): string {
return `
:root {
--color-primary: ${brand.primaryColor};
--color-secondary: ${brand.secondaryColor};
--font-family: '${brand.fontFamily}', sans-serif;
}
body { font-family: var(--font-family); }
.accent, .total-label { color: var(--color-primary); }
.header-bar {
background: var(--color-primary);
height: 4px;
}
${brand.watermark.enabled ? `
body::after {
content: '${brand.watermark.text}';
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(-45deg);
font-size: 72px;
font-weight: 900;
opacity: ${brand.watermark.opacity};
color: var(--color-primary);
pointer-events: none;
z-index: 1000;
}` : ''}
`;
}
// Render PDF with tenant brand injected
async function renderBrandedPdf(
templateSlug: string,
variables: object,
tenantId: string
): Promise<Buffer> {
const tenant = await Tenant.findById(tenantId);
const brand = tenant.brandConfig;
const template = await resolveTemplate(tenantId, templateSlug);
const brandCss = generateBrandCss(brand);
const html = Handlebars.compile(template.html)({
...variables,
brand,
__brandCss: brandCss,
});
const response = await fetch('https://pdf.funbrew.cloud/api/v1/pdf/generate', {
method: 'POST',
headers: {
'X-API-Key': process.env.FUNBREW_PDF_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ html, options: { format: 'A4' } }),
});
return Buffer.from(await response.arrayBuffer());
}
Embedding logos as Base64
External logo URLs may be inaccessible from the PDF rendering environment. Embedding as Base64 is more reliable.
import fetch from 'node-fetch';
async function embedLogoAsBase64(logoUrl: string): Promise<string> {
if (!logoUrl) return '';
try {
const response = await fetch(logoUrl);
const buffer = await response.buffer();
const contentType = response.headers.get('content-type') ?? 'image/png';
const base64 = buffer.toString('base64');
return `data:${contentType};base64,${base64}`;
} catch {
return ''; // fail gracefully; show no logo
}
}
Cost Optimization: Caching, Batching, and Diffing
With multiple tenants generating PDFs continuously, API costs compound quickly.
Strategy 1: Content-hash caching
Cache PDFs by hashing the HTML and options. Identical inputs produce identical PDFs — no need to regenerate.
import crypto from 'crypto';
import { S3Client, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
class PdfCacheService {
constructor(
private readonly s3: S3Client,
private readonly bucket: string,
private readonly redis: RedisClient
) {}
private getCacheKey(html: string, options: object): string {
const content = html + JSON.stringify(options);
return crypto.createHash('sha256').update(content).digest('hex');
}
async getOrGenerate(
html: string,
options: object = {},
ttlSeconds = 86400
): Promise<Buffer> {
const hash = this.getCacheKey(html, options);
const redisKey = `pdf:cache:${hash}`;
const s3Key = `pdf-cache/${hash}.pdf`;
// L1: check Redis for cache existence
const cached = await this.redis.get(redisKey);
if (cached) {
// L2: retrieve PDF from S3
const s3Response = await this.s3.send(new GetObjectCommand({
Bucket: this.bucket,
Key: s3Key,
}));
return streamToBuffer(s3Response.Body);
}
// Cache miss: generate PDF
const response = await fetch('https://pdf.funbrew.cloud/api/v1/pdf/generate', {
method: 'POST',
headers: {
'X-API-Key': process.env.FUNBREW_PDF_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ html, options }),
});
const pdfBuffer = Buffer.from(await response.arrayBuffer());
await this.s3.send(new PutObjectCommand({
Bucket: this.bucket, Key: s3Key,
Body: pdfBuffer, ContentType: 'application/pdf',
}));
await this.redis.setex(redisKey, ttlSeconds, '1');
return pdfBuffer;
}
}
Strategy 2: Diff check to avoid unnecessary regeneration
async function generateIfChanged(
tenantId: string,
entityType: string,
entityId: string,
data: object
): Promise<{ pdfBuffer: Buffer; regenerated: boolean }> {
const dataHash = crypto
.createHash('sha256')
.update(JSON.stringify(data))
.digest('hex');
const existing = await PdfRecord.findOne({ tenantId, entityType, entityId });
if (existing && existing.dataHash === dataHash) {
const pdfBuffer = await downloadFromS3(existing.s3Key);
return { pdfBuffer, regenerated: false };
}
const html = await renderTemplate(tenantId, entityType, data);
const response = await fetch('https://pdf.funbrew.cloud/api/v1/pdf/generate', {
method: 'POST',
headers: {
'X-API-Key': process.env.FUNBREW_PDF_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ html, options: { format: 'A4' } }),
});
const pdfBuffer = Buffer.from(await response.arrayBuffer());
const s3Key = `tenants/${tenantId}/${entityType}/${entityId}.pdf`;
await uploadToS3(s3Key, pdfBuffer);
await PdfRecord.upsert({ tenantId, entityType, entityId, dataHash, s3Key });
return { pdfBuffer, regenerated: true };
}
Strategy 3: Per-tenant usage tracking
async function trackPdfGeneration(
tenantId: string,
generationType: 'sync' | 'async' | 'batch',
count = 1
) {
const month = new Date().toISOString().slice(0, 7);
await db.query(
`INSERT INTO pdf_usage_metrics (tenant_id, month, generation_type, count)
VALUES ($1, $2, $3, $4)
ON CONFLICT (tenant_id, month, generation_type)
DO UPDATE SET count = pdf_usage_metrics.count + EXCLUDED.count`,
[tenantId, month, generationType, count]
);
const totalThisMonth = await getTotalPdfCountForTenant(tenantId, month);
const planLimit = await getPlanPdfLimit(tenantId);
if (totalThisMonth > planLimit * 0.8) {
await notifyTenantApproachingLimit(tenantId, totalThisMonth, planLimit);
}
}
Cost reduction estimates
| Optimization | 10,000/month | 100,000/month |
|---|---|---|
| Caching (30% hit rate) | -3,000 requests | -30,000 requests |
| Diff check (20% unchanged) | -2,000 requests | -20,000 requests |
| Batch API (5 items per call) | -8,000 calls | -80,000 calls |
| Combined (no double-counting) | up to -50% | up to -50% |
Complete Example: Multi-Tenant Invoice PDF Service
Putting it all together: a production-ready invoice PDF service for a multi-tenant SaaS.
System diagram
┌─────────────────────────────────────────┐
│ SaaS frontend │
│ "Download invoice" button │
└───────────────────┬─────────────────────┘
│ POST /invoices/:id/pdf
┌───────────────────▼─────────────────────┐
│ SaaS backend │
│ - Tenant auth and authorization │
│ - Template resolution (custom/default) │
│ - Brand CSS generation │
│ - Cache check │
└─────┬─────────────────────┬─────────────┘
│ cache miss │ cache hit
│ │ → return from S3
┌─────▼─────────────────────│─────────────┐
│ FUNBREW PDF API │ │
│ POST /api/v1/pdf/generate │ │
└─────┬─────────────────────│─────────────┘
│ │
┌─────▼─────────────────────│─────────────┐
│ S3 (PDF storage) │ │
│ tenants/{id}/invoices/ ◄────────────┘
└─────────────────────────────────────────┘
Full service implementation
// services/invoice-pdf.service.ts
import crypto from 'crypto';
import { Tenant, Invoice } from '../models';
import { PdfCacheService } from './pdf-cache.service';
import { renderTemplate, generateBrandCss } from './template.service';
export class InvoicePdfService {
constructor(private readonly cache: PdfCacheService) {}
async generateForTenant(
tenantId: string,
invoiceId: string
): Promise<{ buffer: Buffer; filename: string }> {
const [tenant, invoice] = await Promise.all([
Tenant.findById(tenantId),
Invoice.findByTenantAndId(tenantId, invoiceId),
]);
if (!invoice) throw new NotFoundError(`Invoice ${invoiceId} not found`);
const brandCss = generateBrandCss(tenant.brandConfig);
const html = await renderTemplate(tenantId, 'invoice', {
invoice,
customer: invoice.customer,
brand: tenant.brandConfig,
__brandCss: brandCss,
});
// Cache for 1 hour (invoices may be updated before being finalized)
const pdfBuffer = await this.cache.getOrGenerate(html, { format: 'A4' }, 3600);
await trackPdfGeneration(tenantId, 'sync');
return {
buffer: pdfBuffer,
filename: `invoice-${invoice.number}.pdf`,
};
}
async batchGenerateMonthly(year: number, month: number): Promise<BatchResult> {
const tenants = await Tenant.findAllActive();
const results = [];
const semaphore = new Semaphore(10);
await Promise.allSettled(
tenants.map(async (tenant) => {
await semaphore.acquire();
try {
const invoices = await Invoice.findMonthly(tenant.id, year, month);
if (!invoices.length) return;
const batchItems = await Promise.all(
invoices.map(async (invoice) => ({
html: await renderTemplate(tenant.id, 'invoice', {
invoice,
customer: invoice.customer,
brand: tenant.brandConfig,
__brandCss: generateBrandCss(tenant.brandConfig),
}),
filename: `invoice-${invoice.number}.pdf`,
options: { format: 'A4' },
}))
);
const response = await fetch('https://pdf.funbrew.cloud/api/v1/pdf/batch', {
method: 'POST',
headers: {
'X-API-Key': process.env.FUNBREW_PDF_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ items: batchItems }),
});
const { data } = await response.json();
const failed = await this.storeBatchResults(tenant.id, data.results);
await trackPdfGeneration(tenant.id, 'batch', invoices.length);
results.push({ tenantId: tenant.id, count: invoices.length, failed });
} finally {
semaphore.release();
}
})
);
return { tenants: results };
}
}
Express route
// routes/invoices.ts
router.get('/:id/pdf', requireAuth, async (req, res) => {
try {
const { buffer, filename } = await invoicePdfService.generateForTenant(
req.tenant.id,
req.params.id
);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.setHeader('Cache-Control', 'private, max-age=3600');
res.send(buffer);
} catch (err) {
if (err instanceof NotFoundError) {
return res.status(404).json({ error: err.message });
}
console.error('PDF generation error:', err);
res.status(500).json({ error: 'PDF generation failed. Please try again.' });
}
});
// Internal admin endpoint for monthly batch
router.post('/batch/monthly', requireAdmin, async (req, res) => {
const { year, month } = req.body;
const jobId = await batchJobQueue.enqueue('monthly-invoices', { year, month });
res.json({ jobId, status: 'queued' });
});
Summary: Design Principles for SaaS PDF Integration
| Requirement | Recommended approach |
|---|---|
| User downloads file now | Synchronous generation (under 30 seconds) |
| Large batch or monthly run | Async queue + webhook notification |
| Per-tenant design | Template table with default fallback |
| Brand customization | CSS variables + Base64 logo embedding |
| Cost reduction | Content-hash cache + diff check |
| Rate control | Redis sliding window limiter |
| Tenant notification | HMAC-signed webhook forwarding |
You do not need to implement everything from day one. Start with synchronous generation and basic tenant isolation, then add queuing, caching, and white-labeling as your volume and customer requirements grow.
Deeper coverage of individual components:
- Template design: PDF template engine guide
- Webhook delivery: PDF API webhook integration guide
- Batch processing: PDF batch processing guide
- Production stability: PDF API production checklist
- Security: PDF API security guide
Try the API in the playground, review the endpoint specs in the docs, and see real SaaS use cases in the use cases section.