Invalid Date

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}} &nbsp;/&nbsp; 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:

  1. Build the HTML template with Chart.js graphs (animation: false is required)
  2. Set header/footer optionsdisplayHeaderFooter: true with headerTemplate and footerTemplate
  3. Schedule with cron or APScheduler — fetch last month's data, generate PDF, send email
  4. 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

Powered by FUNBREW PDF