Use case
White-label PDF reports for SaaS
B2B SaaS products often need to let customers download reports that carry the customer's own branding: their logo, color palette, and company name, not yours. Whether you are building an analytics platform, a CRM, or a project management tool, a single API call turns an HTML template into a white-labeled, stored PDF that your customer can share with their own clients.
Generating a branded monthly report (Node.js)
The key pattern is CSS custom properties. Set --brand-primaryto the customer's hex color and put their logo in an <img> tag whose src points to their CDN URL. Every heading, card border, and accent element derives its color from that single variable. Pass store: true so the PDF is retained and the returned document_url is a permanent download link you can save to your database.
The logo must be publicly accessible. If a customer hosts their logo behind authentication or a private origin, copy it to your own CDN and pass that URL instead.
// reports/generate-white-label-report.ts
const PDFPIPE_KEY = process.env.PDFPIPE_KEY!;
const PDFPIPE_URL = "https://api.pdfpipe.xyz/v1/pdf";
type ReportMetric = {
label: string;
value: string;
change?: string;
positive?: boolean;
};
type WhiteLabelReportData = {
customerId: string;
companyName: string;
logoUrl: string; // hosted on a public CDN — must be CORS-accessible
brandColor: string; // hex, e.g. "#2563eb"
reportMonth: string; // e.g. "May 2026"
metrics: ReportMetric[];
summary: string;
};
function buildReportHtml(data: WhiteLabelReportData): string {
const metricCards = data.metrics
.map(
(m) => `
<div class="metric-card">
<div class="metric-label">${m.label}</div>
<div class="metric-value">${m.value}</div>
${m.change ? `<div class="metric-change ${m.positive ? "pos" : "neg"}">${m.change}</div>` : ""}
</div>`
)
.join("");
return `<!DOCTYPE html>
<html>
<head>
<style>
/*
* CSS custom properties carry the customer's brand.
* Swap --brand-primary and --brand-logo per customer — everything else follows.
*/
:root {
--brand-primary: ${data.brandColor};
--brand-logo: url('${data.logoUrl}');
}
body {
font-family: 'Inter', sans-serif;
color: #1a1a1a;
margin: 0;
padding: 48px 56px;
background: #fff;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 40px;
padding-bottom: 20px;
border-bottom: 2px solid var(--brand-primary);
}
/*
* Logo rendered from the customer's CDN URL.
* The image must be publicly accessible. If it is behind auth or a private
* origin, host a copy on your own CDN and pass that URL instead.
*/
.logo {
height: 40px;
width: auto;
object-fit: contain;
}
.report-meta { text-align: right; font-size: 13px; color: #666; }
.report-meta strong { display: block; font-size: 15px; color: #1a1a1a; }
h1 {
font-size: 22px;
font-weight: 700;
margin-bottom: 6px;
color: var(--brand-primary);
}
.subtitle { font-size: 14px; color: #666; margin-bottom: 32px; }
.metrics-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
margin-bottom: 36px;
}
.metric-card {
padding: 18px 20px;
background: #f8f8f8;
border-top: 3px solid var(--brand-primary);
}
.metric-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.07em;
color: #999;
margin-bottom: 6px;
}
.metric-value {
font-size: 20px;
font-weight: 700;
font-family: 'IBM Plex Mono', monospace;
color: #1a1a1a;
}
.metric-change { font-size: 12px; margin-top: 4px; font-family: 'IBM Plex Mono', monospace; }
.pos { color: #166534; }
.neg { color: #991b1b; }
.summary {
font-size: 14px;
line-height: 1.7;
color: #444;
padding: 20px 24px;
background: #f8f8f8;
border-left: 4px solid var(--brand-primary);
margin-bottom: 40px;
}
.footer {
margin-top: 48px;
padding-top: 16px;
border-top: 1px solid #eee;
font-size: 11px;
color: #bbb;
display: flex;
justify-content: space-between;
}
@page { margin: 0; size: A4; }
</style>
</head>
<body>
<div class="header">
<img
class="logo"
src="${data.logoUrl}"
alt="${data.companyName} logo"
/>
<div class="report-meta">
<strong>${data.companyName}</strong>
Monthly Report: ${data.reportMonth}
</div>
</div>
<h1>Performance Report</h1>
<p class="subtitle">${data.reportMonth} | Prepared for ${data.companyName}</p>
<div class="metrics-grid">
${metricCards}
</div>
<div class="summary">${data.summary}</div>
<div class="footer">
<span>Confidential report for ${data.companyName}</span>
<span>Generated ${new Date().toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" })}</span>
</div>
</body>
</html>`;
}
export async function generateWhiteLabelReport(data: WhiteLabelReportData): Promise<string> {
const html = buildReportHtml(data);
const month = data.reportMonth.replace(/\s/g, "-");
const res = await fetch(PDFPIPE_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${PDFPIPE_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
html,
filename: `report-${data.customerId}-${month}.pdf`,
store: true, // persist the PDF so the download link stays valid
options: { format: "A4" },
}),
});
if (!res.ok) {
throw new Error(`PDF generation failed: ${res.status}`);
}
const { document_url } = await res.json();
return document_url; // permanent download link
}Async generation with a download link (webhook pattern)
For on-demand requests from your dashboard, queue the job and return immediately. The worker generates the PDF, writes the document_url to the customer record, and emails the link when ready. This keeps the HTTP response fast and avoids timeouts for large or complex templates.
// api/reports/request.ts
// Customer triggers their monthly report from your dashboard.
// Generation is queued; the download link is delivered via webhook when ready.
import { Queue } from "your-queue-lib"; // e.g. BullMQ, Inngest, Trigger.dev
const reportQueue = new Queue("white-label-reports");
export async function POST(req: Request) {
const { customerId } = await req.json();
const customer = await db.customers.findById(customerId);
if (!customer) return new Response("Not found", { status: 404 });
// Enqueue the job — the worker calls generateWhiteLabelReport and saves the URL
await reportQueue.add("generate", {
customerId: customer.id,
companyName: customer.companyName,
logoUrl: customer.logoUrl,
brandColor: customer.brandColor,
reportMonth: new Intl.DateTimeFormat("en-US", {
month: "long",
year: "numeric",
}).format(new Date()),
metrics: await fetchMetricsForCustomer(customer.id),
summary: await buildSummaryText(customer.id),
});
return Response.json({ queued: true });
}
// --- Worker (runs separately) ---
reportQueue.process("generate", async (job) => {
const url = await generateWhiteLabelReport(job.data);
// Persist the URL so the customer can download from the dashboard at any time
await db.customers.update(job.data.customerId, { latestReportUrl: url });
// Notify the customer
await sendEmail({
to: await db.customers.getEmail(job.data.customerId),
subject: `Your ${job.data.reportMonth} report is ready`,
body: `Download your report: ${url}`,
});
});Month-end batch generation for all customers (TypeScript)
At the end of each month, fetch every active customer, build their report data in parallel, and generate all PDFs with Promise.allSettled so one failure does not abort the rest. Each call to generateWhiteLabelReport stores the PDF automatically. Save the returned URL to the customer record so they can download from the dashboard at any time.
If you have more than a few hundred customers, split them into chunks and run each chunk concurrently to stay within your plan limits while keeping the total runtime short.
// jobs/month-end-reports.ts
// Runs on the 1st of each month at 07:00 UTC.
// Generates one branded report per active customer in a single pass.
import cron from "node-cron";
cron.schedule("0 7 1 * *", async () => {
const reportMonth = new Intl.DateTimeFormat("en-US", {
month: "long",
year: "numeric",
}).format(new Date(Date.now() - 24 * 60 * 60 * 1000)); // previous month
const customers = await db.customers.findAll({ status: "active" });
// Build the report data for every customer in parallel
const reportDataList = await Promise.all(
customers.map(async (customer) => ({
customerId: customer.id,
companyName: customer.companyName,
logoUrl: customer.logoUrl,
brandColor: customer.brandColor,
reportMonth,
metrics: await fetchMetricsForCustomer(customer.id),
summary: await buildSummaryText(customer.id),
}))
);
// Generate all PDFs — each call stores the PDF and returns a permanent URL.
// Use Promise.allSettled so one failure does not abort the whole batch.
const results = await Promise.allSettled(
reportDataList.map((data) => generateWhiteLabelReport(data))
);
for (let i = 0; i < customers.length; i++) {
const result = results[i];
const customer = customers[i];
if (result.status === "fulfilled") {
const url = result.value;
// Save the URL — customers can download from the dashboard any time
await db.customers.update(customer.id, { latestReportUrl: url });
await sendEmail({
to: customer.email,
subject: `Your ${reportMonth} report is ready`,
body: `Download your branded report: ${url}`,
});
} else {
logger.error("Report generation failed", {
customerId: customer.id,
error: result.reason,
});
}
}
logger.info(`Month-end reports: ${results.filter((r) => r.status === "fulfilled").length}/${customers.length} succeeded.`);
});Reusable white-label stylesheet (CSS)
The CSS below is a drop-in stylesheet for any white-label report template. Set --brand-primary at render time and supply the logo URL in the <img> tag. Headers, metric card accents, table headers, and summary blocks all reference the same variable, so swapping the brand for a new customer requires changing exactly two values.
/* white-label-report.css
Drop this stylesheet into your report template.
Set --brand-primary and supply the logo URL in the <img> tag per customer.
Everything else adapts automatically. */
:root {
--brand-primary: #2563eb; /* overridden per customer at render time */
}
/* ---- Page chrome ---- */
.report-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 18px;
border-bottom: 2px solid var(--brand-primary);
margin-bottom: 36px;
}
.report-header .logo {
height: 38px;
width: auto;
object-fit: contain;
}
/* ---- Headings inherit brand color ---- */
h1 { color: var(--brand-primary); font-size: 22px; font-weight: 700; }
h2 { color: var(--brand-primary); font-size: 16px; font-weight: 600; margin-top: 28px; }
/* ---- Metric cards ---- */
.metrics-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin-bottom: 32px;
}
.metric-card {
padding: 16px 18px;
background: #f8f8f8;
border-top: 3px solid var(--brand-primary);
}
.metric-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.07em;
color: #999;
margin-bottom: 4px;
}
.metric-value {
font-size: 20px;
font-weight: 700;
font-family: 'IBM Plex Mono', monospace;
color: #1a1a1a;
}
/* ---- Accent sidebar on summary blocks ---- */
.summary-block {
padding: 18px 22px;
background: #f8f8f8;
border-left: 4px solid var(--brand-primary);
font-size: 14px;
line-height: 1.7;
color: #444;
}
/* ---- Tables ---- */
table { width: 100%; border-collapse: collapse; font-size: 13px; }
thead tr { border-bottom: 2px solid var(--brand-primary); }
th { text-align: left; padding: 8px 10px; font-size: 11px; text-transform: uppercase;
letter-spacing: 0.06em; color: #999; }
td { padding: 10px; border-bottom: 1px solid #f0f0f0; }
tr:nth-child(even) { background: #f9f9f9; }
/* ---- Footer ---- */
.report-footer {
margin-top: 48px;
padding-top: 14px;
border-top: 1px solid #eee;
font-size: 11px;
color: #bbb;
display: flex;
justify-content: space-between;
}Plans
| Plan | Documents/mo | Storage | Price |
|---|---|---|---|
| Hobby | 500 | 1 day | Free |
| Starter | 3,000 | 30 days | $19/mo |
| Growth | 15,000 | 365 days | $49/mo |
| Scale | 50,000 | 2 years | $149/mo |
| Business | 100,000 | 2 years | $499/mo |
Need more than 100,000 documents per month? Contact us for enterprise pricing.