Back to Blog
Guide February 26, 2026 · 4 min read

How to Send Every SaaS Customer a Monthly PDF Usage Report

Generate a personalized PDF usage report for each customer at month-end — their metrics, their data, your brand — and deliver it before they log in to check.

Monthly business reviews, QBRs, usage summaries — enterprise customers expect them. Most SaaS teams either skip them (losing a retention touchpoint) or produce them manually in Google Slides (doesn't scale past 20 accounts).

Here's how to auto-generate a personalized PDF for every customer at month-end: their API calls, their team activity, their top pages — rendered from your own data, emailed before they open Slack on the first of the month.

The pipeline

1st of month cron → fetch all active customers → for each: query their metrics → render HTML → PDF → email

Fetch customer metrics

async function getCustomerMetrics(customerId, month, year) {
  const start = new Date(year, month - 1, 1);
  const end = new Date(year, month, 0, 23, 59, 59);

  const [usage, topUsers, topFeatures, growthData] = await Promise.all([
    db.events.aggregate({
      where: { customerId, createdAt: { gte: start, lte: end } },
      _count: { id: true },
      _sum: { apiCalls: true },
    }),
    db.users.findMany({
      where: { customerId, lastActiveAt: { gte: start } },
      orderBy: { actionsCount: "desc" },
      take: 5,
    }),
    db.featureUsage.groupBy({
      by: ["feature"],
      where: { customerId, date: { gte: start, lte: end } },
      _sum: { count: true },
      orderBy: { _sum: { count: "desc" } },
      take: 5,
    }),
    db.dailyUsage.findMany({
      where: { customerId, date: { gte: start, lte: end } },
      orderBy: { date: "asc" },
    }),
  ]);

  return {
    totalEvents: usage._count.id,
    totalApiCalls: usage._sum.apiCalls ?? 0,
    topUsers,
    topFeatures,
    growthData,
    period: { start, end, month, year },
  };
}

HTML report template

function renderUsageReportHtml({ customer, metrics, prevMetrics }) {
  const monthName = new Date(metrics.period.year, metrics.period.month - 1)
    .toLocaleDateString("en-US", { month: "long", year: "numeric" });

  const pctChange = (curr, prev) => {
    if (!prev) return null;
    const delta = ((curr - prev) / prev) * 100;
    return { value: Math.abs(delta).toFixed(0), up: delta >= 0 };
  };

  const eventChange = pctChange(metrics.totalEvents, prevMetrics?.totalEvents);
  const apiChange = pctChange(metrics.totalApiCalls, prevMetrics?.totalApiCalls);

  const topUsersRows = metrics.topUsers
    .map((u, i) => `
      <tr>
        <td>${i + 1}</td>
        <td>${u.name}</td>
        <td style="text-align:right">${u.actionsCount.toLocaleString()}</td>
      </tr>`)
    .join("");

  const topFeaturesRows = metrics.topFeatures
    .map((f) => `
      <tr>
        <td>${f.feature}</td>
        <td style="text-align:right">${f._sum.count.toLocaleString()}</td>
      </tr>`)
    .join("");

  return `<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <style>
    body { font-family: -apple-system, 'Segoe UI', sans-serif; color: #111; max-width: 760px; margin: 0 auto; padding: 40px; }
    .header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 32px; padding-bottom: 20px; border-bottom: 2px solid #111; }
    .logo { font-size: 22px; font-weight: 800; }
    .report-title { font-size: 14px; color: #666; }
    .customer-name { font-size: 18px; font-weight: 700; }
    .metrics-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 36px; }
    .metric { background: #f8f8f8; border-radius: 8px; padding: 20px; }
    .metric-value { font-size: 32px; font-weight: 800; line-height: 1; }
    .metric-label { color: #666; font-size: 12px; margin-top: 4px; }
    .metric-change { font-size: 12px; margin-top: 6px; }
    .up { color: #16a34a; } .down { color: #dc2626; }
    h2 { font-size: 14px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; color: #666; margin: 28px 0 10px; }
    table { width: 100%; border-collapse: collapse; font-size: 13px; }
    th { text-align: left; border-bottom: 2px solid #111; padding: 6px 0; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; }
    td { padding: 8px 0; border-bottom: 1px solid #f0f0f0; }
    .footer { margin-top: 48px; padding-top: 16px; border-top: 1px solid #eee; color: #999; font-size: 11px; }
  </style>
</head>
<body>
  <div class="header">
    <div class="logo">YourProduct</div>
    <div style="text-align:right">
      <div class="report-title">Monthly Usage Report · ${monthName}</div>
      <div class="customer-name">${customer.name}</div>
    </div>
  </div>

  <div class="metrics-grid">
    <div class="metric">
      <div class="metric-value">${metrics.totalEvents.toLocaleString()}</div>
      <div class="metric-label">Total events</div>
      ${eventChange ? `<div class="metric-change ${eventChange.up ? "up" : "down"}">${eventChange.up ? "↑" : "↓"} ${eventChange.value}% vs last month</div>` : ""}
    </div>
    <div class="metric">
      <div class="metric-value">${metrics.totalApiCalls.toLocaleString()}</div>
      <div class="metric-label">API calls</div>
      ${apiChange ? `<div class="metric-change ${apiChange.up ? "up" : "down"}">${apiChange.up ? "↑" : "↓"} ${apiChange.value}% vs last month</div>` : ""}
    </div>
    <div class="metric">
      <div class="metric-value">${metrics.topUsers.length}</div>
      <div class="metric-label">Active users</div>
    </div>
  </div>

  <h2>Most active users</h2>
  <table>
    <thead><tr><th>#</th><th>User</th><th style="text-align:right">Actions</th></tr></thead>
    <tbody>${topUsersRows}</tbody>
  </table>

  <h2>Top features used</h2>
  <table>
    <thead><tr><th>Feature</th><th style="text-align:right">Uses</th></tr></thead>
    <tbody>${topFeaturesRows}</tbody>
  </table>

  <div class="footer">
    Questions? Reply to this email or contact ${customer.successManager ?? "support@yourproduct.com"}<br>
    Manage your account at app.yourproduct.com/settings
  </div>
</body>
</html>`;
}

Monthly batch job

import cron from "node-cron";

// 1st of every month at 7am UTC
cron.schedule("0 7 1 * *", () => runMonthlyReports());

async function runMonthlyReports() {
  const now = new Date();
  const month = now.getMonth() === 0 ? 12 : now.getMonth();
  const year = now.getMonth() === 0 ? now.getFullYear() - 1 : now.getFullYear();

  const customers = await db.customers.findMany({
    where: { plan: { in: ["growth", "scale"] }, monthlyReport: true },
    include: { primaryContact: true },
  });

  console.log(`Generating reports for ${customers.length} customers (${month}/${year})`);

  for (let i = 0; i < customers.length; i += 5) {
    const batch = customers.slice(i, i + 5);
    await Promise.allSettled(
      batch.map(async (customer) => {
        const [metrics, prevMetrics] = await Promise.all([
          getCustomerMetrics(customer.id, month, year),
          getCustomerMetrics(customer.id, month === 1 ? 12 : month - 1, month === 1 ? year - 1 : year),
        ]);

        const html = renderUsageReportHtml({ customer, metrics, prevMetrics });

        const res = await fetch("https://pagebolt.dev/api/v1/pdf", {
          method: "POST",
          headers: { "x-api-key": process.env.PAGEBOLT_API_KEY, "Content-Type": "application/json" },
          body: JSON.stringify({ html }),
        });

        const pdf = Buffer.from(await res.arrayBuffer());
        await emailReport({ customer, pdf, month, year });
        console.log(`  ✓ ${customer.name}`);
      })
    );
  }
}

Email delivery

async function emailReport({ customer, pdf, month, year }) {
  const monthName = new Date(year, month - 1).toLocaleDateString("en-US", { month: "long", year: "numeric" });

  await mailer.send({
    to: customer.primaryContact.email,
    subject: `Your ${monthName} usage report — ${customer.name}`,
    html: `
      <p>Hi ${customer.primaryContact.firstName},</p>
      <p>Your ${monthName} usage summary for ${customer.name} is attached.</p>
      <p>Questions? Just reply to this email.</p>
    `,
    attachments: [{
      filename: `usage-report-${year}-${String(month).padStart(2, "0")}.pdf`,
      content: pdf,
      contentType: "application/pdf",
    }],
  });
}

One cron job, one email per customer on the first of every month. Customers who receive proactive reports churn less — it's a retention touchpoint that costs almost nothing to run.

Try it free

100 requests/month, no credit card required. OG images, screenshots, PDFs, and video — one API.

Get API Key — Free