Still spending hours each month reformatting dashboards into PDF reports? Copying KPI numbers into slide decks and exporting them one by one? There's a better way.
With the FUNBREW PDF API, you can automate the entire pipeline — from pulling data to rendering Chart.js graphs, adding headers and page numbers, and emailing the finished PDF — with a few dozen lines of code.
This guide covers the full implementation with real Node.js and Python examples.
Business Report Use Cases
PDF automation has the highest ROI in these three scenarios.
1. Monthly Management Reports
The board-level summary delivered at month-end or quarter-end: revenue trends, cost breakdowns, KPI attainment. Manual preparation takes hours. With a PDF generation API, you pass the data, get back a PDF, and send it — all in under a minute.
2. KPI Dashboard Reports
Data lives in Google Analytics, Salesforce, or BigQuery. Your internal dashboard is great for real-time monitoring, but stakeholders often want a "snapshot in time" they can read offline or forward without login credentials. PDF is the natural format. See the report automation use cases for more patterns.
3. Client Reports
Agencies, consultancies, and SaaS companies send performance reports to each client every month. With a template and an API call per client, you generate 50 branded reports in the time it used to take to make one.
Rendering Dynamic Charts with HTML + Chart.js
FUNBREW PDF renders HTML using headless Chromium, so Chart.js (and other JavaScript charting libraries) work out of the box. The charts render at full quality in the PDF.
Chart.js Report Template
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, 'Segoe UI', sans-serif;
font-size: 13px;
color: #1e293b;
background: #fff;
padding: 40px;
}
h1 { font-size: 22px; font-weight: 700; margin-bottom: 4px; }
h2 { font-size: 15px; font-weight: 600; margin-bottom: 16px; color: #334155; }
.meta { color: #64748b; font-size: 12px; margin-bottom: 32px; }
.kpi-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 32px;
}
.kpi-card {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 16px;
}
.kpi-label { font-size: 11px; color: #64748b; margin-bottom: 4px; }
.kpi-value { font-size: 24px; font-weight: 700; }
.kpi-change { font-size: 11px; margin-top: 4px; }
.kpi-change.up { color: #16a34a; }
.kpi-change.down { color: #dc2626; }
.chart-section { margin-bottom: 32px; }
canvas { max-width: 100%; }
</style>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
</head>
<body>
<h1>Monthly Performance Report</h1>
<p class="meta">Period: {{period}} / Generated: {{created_at}}</p>
<!-- KPI Cards -->
<div class="kpi-grid">
<div class="kpi-card">
<div class="kpi-label">Total Revenue</div>
<div class="kpi-value">${{revenue}}</div>
<div class="kpi-change up">▲ {{revenue_change}}% vs last month</div>
</div>
<div class="kpi-card">
<div class="kpi-label">New Customers</div>
<div class="kpi-value">{{new_customers}}</div>
<div class="kpi-change up">▲ {{customer_change}}% vs last month</div>
</div>
<div class="kpi-card">
<div class="kpi-label">Conversion Rate</div>
<div class="kpi-value">{{conversion_rate}}%</div>
<div class="kpi-change down">▼ {{conversion_change}}% vs last month</div>
</div>
<div class="kpi-card">
<div class="kpi-label">Churn Rate</div>
<div class="kpi-value">{{churn_rate}}%</div>
<div class="kpi-change up">▲ Improved</div>
</div>
</div>
<!-- Revenue Trend Chart -->
<div class="chart-section">
<h2>Revenue Trend (Last 6 Months)</h2>
<canvas id="revenueChart" height="120"></canvas>
</div>
<!-- Category Breakdown Chart -->
<div class="chart-section">
<h2>Revenue by Category</h2>
<canvas id="categoryChart" height="100"></canvas>
</div>
<script>
const months = {{months_json}};
const revenueData = {{revenue_data_json}};
const categoryLabels = {{category_labels_json}};
const categoryData = {{category_data_json}};
new Chart(document.getElementById('revenueChart'), {
type: 'bar',
data: {
labels: months,
datasets: [{
label: 'Revenue ($K)',
data: revenueData,
backgroundColor: 'rgba(59, 130, 246, 0.7)',
borderColor: 'rgb(59, 130, 246)',
borderWidth: 1,
borderRadius: 4,
}]
},
options: {
responsive: true,
animation: false, // Required for PDF generation
plugins: { legend: { display: false } },
scales: { y: { beginAtZero: true } }
}
});
new Chart(document.getElementById('categoryChart'), {
type: 'doughnut',
data: {
labels: categoryLabels,
datasets: [{
data: categoryData,
backgroundColor: ['#3b82f6','#10b981','#f59e0b','#ef4444','#8b5cf6'],
}]
},
options: {
responsive: true,
animation: false,
plugins: { legend: { position: 'right' } }
}
});
</script>
</body>
</html>
animation: false is required. Without it, the PDF may capture the chart mid-animation and render blank or partial graphs.
Adding Headers, Footers, and Page Numbers
Professional business reports need a company header on every page and a page number in the footer. The FUNBREW PDF API handles this via headerTemplate and footerTemplate options.
Header and Footer HTML
<!-- headerTemplate: rendered at top of every page -->
<div style="
width: 100%;
padding: 0 40px;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 10px;
color: #94a3b8;
font-family: -apple-system, sans-serif;
">
<span>Acme Corp — Monthly Performance Report</span>
<span>CONFIDENTIAL</span>
</div>
<!-- footerTemplate: rendered at bottom of every page -->
<div style="
width: 100%;
padding: 0 40px;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 10px;
color: #94a3b8;
font-family: -apple-system, sans-serif;
">
<span>Generated April 1, 2026</span>
<!-- Chromium built-in variables for page numbers -->
<span>Page <span class="pageNumber"></span> of <span class="totalPages"></span></span>
</div>
class="pageNumber" and class="totalPages" are Chromium built-in variables — they're automatically replaced with the correct values on each page.
Node.js Implementation
Generating a Report PDF
import fs from 'fs';
import path from 'path';
const API_KEY = process.env.FUNBREW_PDF_API_KEY;
const API_URL = 'https://pdf.funbrew.cloud/api/v1/pdf/generate';
async function generateMonthlyReport(reportData) {
const templatePath = path.join('templates', 'monthly-report.html');
let html = fs.readFileSync(templatePath, 'utf8');
// Replace placeholders with real data
html = html
.replace('{{period}}', reportData.period)
.replace('{{created_at}}', new Date().toLocaleDateString('en-US', { dateStyle: 'long' }))
.replace('{{revenue}}', reportData.revenue.toLocaleString())
.replace('{{revenue_change}}', reportData.revenueChange)
.replace('{{new_customers}}', reportData.newCustomers)
.replace('{{customer_change}}', reportData.customerChange)
.replace('{{conversion_rate}}', reportData.conversionRate)
.replace('{{conversion_change}}', reportData.conversionChange)
.replace('{{churn_rate}}', reportData.churnRate)
.replace('{{months_json}}', JSON.stringify(reportData.months))
.replace('{{revenue_data_json}}', JSON.stringify(reportData.revenueData))
.replace('{{category_labels_json}}', JSON.stringify(reportData.categoryLabels))
.replace('{{category_data_json}}', JSON.stringify(reportData.categoryData));
const response = await fetch(API_URL, {
method: 'POST',
headers: {
'X-API-Key': API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
html,
options: {
format: 'A4',
margin: {
top: '60px', // Space for header
bottom: '50px', // Space for footer
left: '0px',
right: '0px',
},
displayHeaderFooter: true,
headerTemplate: buildHeaderTemplate('Monthly Performance Report'),
footerTemplate: buildFooterTemplate(),
printBackground: true,
waitForNetworkIdle: true, // Wait for Chart.js CDN to load
},
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(`PDF generation failed: ${error.message}`);
}
return Buffer.from(await response.arrayBuffer());
}
function buildHeaderTemplate(title) {
return `
<div style="width:100%;padding:0 40px;display:flex;justify-content:space-between;
align-items:center;font-size:10px;color:#94a3b8;font-family:-apple-system,sans-serif;">
<span>Acme Corp — ${title}</span>
<span>CONFIDENTIAL</span>
</div>`;
}
function buildFooterTemplate() {
const date = new Date().toLocaleDateString('en-US', { dateStyle: 'long' });
return `
<div style="width:100%;padding:0 40px;display:flex;justify-content:space-between;
align-items:center;font-size:10px;color:#94a3b8;font-family:-apple-system,sans-serif;">
<span>Generated ${date}</span>
<span>Page <span class="pageNumber"></span> of <span class="totalPages"></span></span>
</div>`;
}
// Usage
const pdfBuffer = await generateMonthlyReport({
period: 'March 2026',
revenue: 125000,
revenueChange: 8.3,
newCustomers: 47,
customerChange: 12.1,
conversionRate: 23.4,
conversionChange: -1.2,
churnRate: 1.8,
months: ['Oct', 'Nov', 'Dec', 'Jan', 'Feb', 'Mar'],
revenueData: [98, 105, 112, 108, 115, 125],
categoryLabels: ['SaaS', 'Consulting', 'Support', 'Other'],
categoryData: [65, 20, 10, 5],
});
fs.writeFileSync('monthly-report-2026-03.pdf', pdfBuffer);
console.log('Report generated successfully');
Python Implementation
import os
import json
import requests
from datetime import datetime
from pathlib import Path
API_KEY = os.environ["FUNBREW_PDF_API_KEY"]
API_URL = "https://pdf.funbrew.cloud/api/v1/pdf/generate"
def generate_monthly_report(report_data: dict) -> bytes:
"""Generate a monthly report PDF and return it as bytes."""
template_path = Path("templates") / "monthly-report.html"
html = template_path.read_text(encoding="utf-8")
replacements = {
"{{period}}": report_data["period"],
"{{created_at}}": datetime.now().strftime("%B %d, %Y"),
"{{revenue}}": f"{report_data['revenue']:,}",
"{{revenue_change}}": str(report_data["revenue_change"]),
"{{new_customers}}": str(report_data["new_customers"]),
"{{customer_change}}": str(report_data["customer_change"]),
"{{conversion_rate}}": str(report_data["conversion_rate"]),
"{{conversion_change}}": str(report_data["conversion_change"]),
"{{churn_rate}}": str(report_data["churn_rate"]),
"{{months_json}}": json.dumps(report_data["months"]),
"{{revenue_data_json}}": json.dumps(report_data["revenue_data"]),
"{{category_labels_json}}": json.dumps(report_data["category_labels"]),
"{{category_data_json}}": json.dumps(report_data["category_data"]),
}
for placeholder, value in replacements.items():
html = html.replace(placeholder, value)
response = requests.post(
API_URL,
headers={"X-API-Key": API_KEY},
json={
"html": html,
"options": {
"format": "A4",
"margin": {"top": "60px", "bottom": "50px", "left": "0px", "right": "0px"},
"displayHeaderFooter": True,
"headerTemplate": build_header("Monthly Performance Report"),
"footerTemplate": build_footer(),
"printBackground": True,
"waitForNetworkIdle": True,
},
},
)
response.raise_for_status()
return response.content
def build_header(title: str) -> str:
style = ("width:100%;padding:0 40px;display:flex;justify-content:space-between;"
"align-items:center;font-size:10px;color:#94a3b8;font-family:sans-serif;")
return f'<div style="{style}"><span>Acme Corp — {title}</span><span>CONFIDENTIAL</span></div>'
def build_footer() -> str:
style = ("width:100%;padding:0 40px;display:flex;justify-content:space-between;"
"align-items:center;font-size:10px;color:#94a3b8;font-family:sans-serif;")
date_str = datetime.now().strftime("%B %d, %Y")
return (
f'<div style="{style}">'
f'<span>Generated {date_str}</span>'
f'<span>Page <span class="pageNumber"></span> of <span class="totalPages"></span></span>'
f'</div>'
)
# Usage
if __name__ == "__main__":
data = {
"period": "March 2026",
"revenue": 125_000,
"revenue_change": 8.3,
"new_customers": 47,
"customer_change": 12.1,
"conversion_rate": 23.4,
"conversion_change": -1.2,
"churn_rate": 1.8,
"months": ["Oct", "Nov", "Dec", "Jan", "Feb", "Mar"],
"revenue_data": [98, 105, 112, 108, 115, 125],
"category_labels": ["SaaS", "Consulting", "Support", "Other"],
"category_data": [65, 20, 10, 5],
}
pdf = generate_monthly_report(data)
Path("monthly-report-2026-03.pdf").write_bytes(pdf)
print("Report generated successfully")
Scheduling with Cron
Automate the full pipeline — fetch data, generate PDF, send email — on the first of every month.
Node.js with node-cron
import cron from 'node-cron';
import { generateMonthlyReport } from './report-generator.js';
import { sendReportEmail } from './mailer.js';
import { fetchReportData } from './data-fetcher.js';
// Run at 09:00 on the 1st of every month
cron.schedule('0 9 1 * *', async () => {
console.log('Monthly report job started:', new Date().toISOString());
try {
const lastMonth = getPreviousMonth();
const reportData = await fetchReportData(lastMonth);
const pdfBuffer = await generateMonthlyReport(reportData);
await sendReportEmail({
to: ['ceo@acme.com', 'cfo@acme.com'],
subject: `Monthly Report — ${lastMonth.label}`,
body: `The ${lastMonth.label} performance report is attached.`,
attachment: {
filename: `monthly-report-${lastMonth.slug}.pdf`,
content: pdfBuffer,
contentType: 'application/pdf',
},
});
console.log('Monthly report delivered successfully');
} catch (err) {
console.error('Report generation failed:', err);
await notifyError(err);
}
});
function getPreviousMonth() {
const now = new Date();
const year = now.getMonth() === 0 ? now.getFullYear() - 1 : now.getFullYear();
const month = now.getMonth() === 0 ? 12 : now.getMonth();
return {
label: new Date(year, month - 1).toLocaleString('en-US', { month: 'long', year: 'numeric' }),
slug: `${year}-${String(month).padStart(2, '0')}`,
year,
month,
};
}
Python with APScheduler
from apscheduler.schedulers.blocking import BlockingScheduler
from datetime import datetime
from dateutil.relativedelta import relativedelta
from report_generator import generate_monthly_report
from mailer import send_report_email
from data_fetcher import fetch_report_data
scheduler = BlockingScheduler(timezone="America/New_York")
@scheduler.scheduled_job("cron", day=1, hour=9, minute=0)
def monthly_report_job():
print(f"Monthly report job started: {datetime.now()}")
try:
last_month = datetime.now() - relativedelta(months=1)
report_data = fetch_report_data(last_month.year, last_month.month)
pdf = generate_monthly_report(report_data)
send_report_email(
to=["ceo@acme.com", "cfo@acme.com"],
subject=f"Monthly Report — {last_month.strftime('%B %Y')}",
pdf=pdf,
filename=f"monthly-report-{last_month.strftime('%Y-%m')}.pdf",
)
print("Monthly report delivered")
except Exception as e:
print(f"Error: {e}")
notify_error(e)
if __name__ == "__main__":
scheduler.start()
Linux crontab
# Edit with: crontab -e
# Run on the 1st of every month at 09:00
0 9 1 * * cd /opt/myapp && node generate-report.js >> /var/log/report.log 2>&1
Webhook Integration for Email Delivery
Instead of emailing the PDF directly from the generation script, you can use FUNBREW PDF's webhook to trigger downstream actions asynchronously. See the Webhook integration guide for the full implementation.
Webhook Payload
{
"event": "pdf.generated",
"timestamp": "2026-04-01T09:00:05Z",
"data": {
"id": "pdf_rpt_abc123",
"filename": "monthly-report-2026-03.pdf",
"file_size": 182450,
"download_url": "https://pdf.funbrew.cloud/dl/abc123?expires=1743600000",
"generation_time_ms": 3200
}
}
Handling the Webhook in Node.js
import express from 'express';
import nodemailer from 'nodemailer';
const app = express();
app.use(express.json());
app.post('/webhooks/pdf-report', async (req, res) => {
const { event, data } = req.body;
if (event !== 'pdf.generated') {
return res.json({ received: true });
}
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
await transporter.sendMail({
from: 'reports@acme.com',
to: ['ceo@acme.com', 'cfo@acme.com'],
subject: 'Monthly Report — March 2026 is ready',
html: `
<p>Your monthly performance report is ready.</p>
<p><a href="${data.download_url}">Download the PDF</a> (link valid for 24 hours)</p>
<p>File size: ${(data.file_size / 1024).toFixed(1)} KB</p>
`,
});
res.json({ received: true });
});
app.listen(3000);
For invoice-style reports with direct attachment delivery, see the invoice PDF automation guide.
Batch Generation for Multiple Clients
For agencies managing many clients, generate all reports in a single batch request. See the batch processing guide for details on large-scale generation.
// Build HTML for each client
const clientReports = await Promise.all(
clients.map(async (client) => {
const data = await fetchClientData(client.id, targetMonth);
const html = buildReportHtml(data, client.branding);
return { clientId: client.id, html, filename: `report-${client.slug}-${targetMonth}.pdf` };
})
);
// Generate all PDFs in one API call
const response = await fetch('https://pdf.funbrew.cloud/api/v1/pdf/batch', {
method: 'POST',
headers: {
'X-API-Key': API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
items: clientReports.map(r => ({
html: r.html,
filename: r.filename,
options: {
format: 'A4',
margin: { top: '60px', bottom: '50px', left: '0px', right: '0px' },
displayHeaderFooter: true,
headerTemplate: buildHeaderTemplate(r.clientId),
footerTemplate: buildFooterTemplate(),
printBackground: true,
waitForNetworkIdle: true,
},
})),
}),
});
const { data } = await response.json();
console.log(`Generated ${data.results.length} client reports`);
Frequently Asked Questions
Charts are rendering blank in the PDF
Two common causes: animation: false is missing on your Chart.js config, or waitForNetworkIdle: true is not set (so the CDN hasn't finished loading). The most reliable fix is to inline Chart.js directly in your HTML rather than loading it from a CDN. See the HTML to PDF CSS tips guide for rendering best practices.
Can I use a paper size other than A4?
Yes. Set format to "Letter", "A3", "Legal", or any custom size like "2480px 3508px".
How do I force a page break?
Use CSS break-before: page on the element where you want the new page to start:
<div style="break-before: page;">
<!-- This section starts on a new page -->
</div>
Is it secure for confidential reports?
All communication uses SSL/TLS encryption. API requests are authenticated with your X-API-Key. Download URLs have a built-in expiration time. For a full security overview, see the PDF API security guide.
Does it support non-Latin fonts?
Yes. Noto Sans and CJK fonts are pre-installed. Specify font-family: 'Noto Sans JP', sans-serif; for Japanese, for example — no additional font configuration needed.
Try It in the Playground
Before writing code, paste your HTML template into the Playground and see the PDF output instantly. It's the fastest way to iterate on chart layouts and header/footer positioning.
Summary
Automating business report PDFs comes down to four steps:
- Build the HTML template with Chart.js graphs (
animation: falseis required) - Set header/footer options —
displayHeaderFooter: truewithheaderTemplateandfooterTemplate - Schedule with cron or APScheduler — fetch last month's data, generate PDF, send email
- Use webhooks for async delivery — trigger email or Slack notifications after generation
Start with the free plan (30 PDFs/month), build your template in the Playground, and refer to the API reference for the full list of options.
Related
- Report Automation Use Cases — Business report generation patterns
- PDF API Webhook Integration — Async completion notifications and Slack alerts
- PDF Batch Processing Guide — Generating multiple reports at once
- Automate Invoice PDF Generation — Email delivery with attachments
- HTML to PDF CSS Tips — Chart rendering and layout best practices
- PDF API Security Guide — Secure handling of confidential report data
- PDF API Quickstart by Language — Node.js, Python, PHP basics
- HTML to PDF API Comparison 2026 — How FUNBREW PDF compares to alternatives
- PDF Template Engine Guide — Variables, loops, and conditionals
- API Reference — Full endpoint documentation