How to Generate PDF Payslips Automatically on Payroll Run
Issue branded PDF payslips to every employee on payroll day — triggered by your payroll provider webhook or a scheduled job. No Word templates, no manual exports, no per-employee clicking.
Payroll software generates the numbers. Delivering a branded, professional payslip to each employee — as a PDF, on time, every pay period — is a separate problem. Most teams solve it manually: open a template, fill in the values, export, email. Multiply that by 50 employees every two weeks and it's hours of repetitive work.
Here's how to automate it: payroll webhook fires → generate one PDF per employee → email delivered before they check their bank.
Payslip HTML template
function renderPayslipHtml({
employeeName, employeeId, jobTitle, department,
payPeriodStart, payPeriodEnd, payDate,
earnings, deductions, netPay, ytdGross, ytdNet,
currency = "USD",
}) {
const fmt = (n) =>
new Intl.NumberFormat("en-US", { style: "currency", currency }).format(n);
const grossPay = earnings.reduce((s, e) => s + e.amount, 0);
const totalDeductions = deductions.reduce((s, d) => s + d.amount, 0);
const earningsRows = earnings
.map((e) => `<tr><td>${e.label}</td><td class="amount">${fmt(e.amount)}</td></tr>`)
.join("");
const deductionRows = deductions
.map((d) => `<tr><td>${d.label}</td><td class="amount red">(${fmt(d.amount)})</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: 680px; margin: 0 auto; padding: 32px 40px; font-size: 13px; }
.header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 24px; padding-bottom: 20px; border-bottom: 2px solid #111; }
.logo { font-size: 20px; font-weight: 800; }
.doc-title { text-align: right; }
.doc-title h1 { font-size: 16px; margin: 0 0 4px; }
.doc-title .period { color: #666; font-size: 12px; }
.employee-info { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; background: #f8f8f8; padding: 16px; border-radius: 6px; margin-bottom: 24px; }
.info-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.8px; color: #999; margin-bottom: 2px; }
.info-value { font-weight: 600; font-size: 13px; }
.columns { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin-bottom: 24px; }
.section h2 { font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #666; margin: 0 0 8px; }
table { width: 100%; border-collapse: collapse; }
td { padding: 6px 0; border-bottom: 1px solid #f0f0f0; }
td.amount { text-align: right; font-variant-numeric: tabular-nums; }
td.red { color: #dc2626; }
.section-total td { font-weight: 700; border-top: 1.5px solid #111; border-bottom: none; padding-top: 8px; }
.net-pay-box { background: #111; color: white; padding: 20px 24px; border-radius: 6px; display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.net-pay-label { font-size: 12px; opacity: 0.7; margin-bottom: 4px; }
.net-pay-amount { font-size: 28px; font-weight: 800; }
.ytd { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; background: #f8f8f8; padding: 14px 16px; border-radius: 6px; }
.ytd-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.8px; color: #999; margin-bottom: 2px; }
.ytd-value { font-weight: 700; font-size: 14px; }
.footer { margin-top: 24px; font-size: 10px; color: #bbb; text-align: center; }
</style>
</head>
<body>
<div class="header">
<div class="logo">YourCompany</div>
<div class="doc-title">
<h1>Payslip</h1>
<div class="period">Pay date: ${payDate}</div>
</div>
</div>
<div class="employee-info">
<div class="info-group"><div class="info-label">Employee</div><div class="info-value">${employeeName}</div></div>
<div class="info-group"><div class="info-label">Employee ID</div><div class="info-value">${employeeId}</div></div>
<div class="info-group"><div class="info-label">Job title</div><div class="info-value">${jobTitle}</div></div>
<div class="info-group"><div class="info-label">Department</div><div class="info-value">${department}</div></div>
<div class="info-group"><div class="info-label">Pay period</div><div class="info-value">${payPeriodStart} – ${payPeriodEnd}</div></div>
</div>
<div class="columns">
<div class="section">
<h2>Earnings</h2>
<table>
<tbody>${earningsRows}</tbody>
<tr class="section-total"><td>Gross pay</td><td class="amount">${fmt(grossPay)}</td></tr>
</table>
</div>
<div class="section">
<h2>Deductions</h2>
<table>
<tbody>${deductionRows}</tbody>
<tr class="section-total"><td>Total deductions</td><td class="amount red">(${fmt(totalDeductions)})</td></tr>
</table>
</div>
</div>
<div class="net-pay-box">
<div>
<div class="net-pay-label">Net pay</div>
<div class="net-pay-amount">${fmt(netPay)}</div>
</div>
<div style="text-align:right;font-size:12px;opacity:0.7">
<div>Deposited</div>
<div style="font-weight:700;font-size:14px;opacity:1">${payDate}</div>
</div>
</div>
<div class="ytd">
<div><div class="ytd-label">YTD Gross</div><div class="ytd-value">${fmt(ytdGross)}</div></div>
<div><div class="ytd-label">YTD Net</div><div class="ytd-value">${fmt(ytdNet)}</div></div>
</div>
<div class="footer">This payslip is generated automatically. Questions? Contact hr@yourcompany.com</div>
</body>
</html>`;
}
Generate and email payslips for all employees
async function runPayroll(payrollData) {
const { payDate, employees } = payrollData;
console.log(`Generating ${employees.length} payslips for ${payDate}...`);
const results = await Promise.allSettled(
employees.map(async (employee) => {
const html = renderPayslipHtml(employee);
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 }),
});
if (!res.ok) throw new Error(`PDF failed for ${employee.employeeName}: ${res.status}`);
const pdf = Buffer.from(await res.arrayBuffer());
// Store in S3
const key = `payslips/${employee.employeeId}/${payDate}.pdf`;
await uploadToS3(pdf, key);
// Email to employee
await sendPayslipEmail({ employee, pdf, payDate });
console.log(` ✓ ${employee.employeeName}`);
return { employeeId: employee.employeeId, ok: true };
})
);
const failed = results.filter((r) => r.status === "rejected");
if (failed.length) {
console.error(`${failed.length} payslips failed:`);
failed.forEach((r) => console.error(` - ${r.reason}`));
}
console.log(`Done: ${results.length - failed.length}/${employees.length} delivered`);
}
Gusto webhook integration
app.post("/webhooks/gusto/payroll", async (req, res) => {
// Verify Gusto signature
const sig = req.headers["x-gusto-signature"];
if (!verifyGustoSignature(req.rawBody, sig)) {
return res.status(401).send("Unauthorized");
}
res.json({ ok: true }); // acknowledge before processing
const { payroll } = req.body;
if (payroll.processed !== true) return;
// Map Gusto payroll data to our template format
const employees = payroll.employee_compensations.map((ec) => ({
employeeId: ec.employee_uuid,
employeeName: ec.employee_name,
jobTitle: ec.job_title,
department: ec.department,
payDate: payroll.pay_date,
payPeriodStart: payroll.pay_period.start_date,
payPeriodEnd: payroll.pay_period.end_date,
earnings: ec.fixed_compensations.map((c) => ({
label: c.name,
amount: parseFloat(c.amount),
})),
deductions: ec.employee_deductions.map((d) => ({
label: d.name,
amount: parseFloat(d.amount),
})),
netPay: parseFloat(ec.net_pay),
ytdGross: parseFloat(ec.ytd_gross),
ytdNet: parseFloat(ec.ytd_net),
}));
await runPayroll({ payDate: payroll.pay_date, employees });
});
Employee self-service: download any payslip
// Authenticated endpoint — employees can download their own payslips
app.get("/payslips/:date", requireAuth, async (req, res) => {
const { date } = req.params;
const key = `payslips/${req.user.employeeId}/${date}.pdf`;
const url = await getSignedS3Url(key, 300); // 5-minute expiry
res.redirect(url);
});
// List all payslips for the logged-in employee
app.get("/payslips", requireAuth, async (req, res) => {
const payslips = await listS3Objects(`payslips/${req.user.employeeId}/`);
res.json(payslips.map((key) => ({
date: key.split("/").pop().replace(".pdf", ""),
downloadUrl: `/payslips/${key.split("/").pop().replace(".pdf", "")}`,
})));
});
No Word templates. No manual exports. One payroll run = one API batch = every employee's inbox updated before they check their account.
Try it free
100 requests/month, no credit card required. OG images, screenshots, PDFs, and video — one API.
Get API Key — Free