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

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