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