DEV Community

Custodia-Admin
Custodia-Admin

Posted on Originally published at pagebolt.dev

How to generate PDF payslips automatically on payroll run

How to Generate PDF Payslips Automatically on Payroll Run

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-group { }
    .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;
      font-size: 13px;
    }

    .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; }
    .net-pay-date { text-align: right; font-size: 12px; opacity: 0.7; }

    .ytd {
      display: grid;
      grid-template-columns: 1fr 1fr;
      gap: 12px;
      background: #f8f8f8;
      padding: 14px 16px;
      border-radius: 6px;
    }
    .ytd-item { }
    .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 class="net-pay-date">
      <div>Deposited</div>
      <div style="font-weight:700;font-size:14px;opacity:1">${payDate}</div>
    </div>
  </div>

  <div class="ytd">
    <div class="ytd-item">
      <div class="ytd-label">YTD Gross</div>
      <div class="ytd-value">${fmt(ytdGross)}</div>
    </div>
    <div class="ytd-item">
      <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>`;
}
Enter fullscreen mode Exit fullscreen mode

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`);
}
Enter fullscreen mode Exit fullscreen mode

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 });
});
Enter fullscreen mode Exit fullscreen mode

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", "")}`,
  })));
});
Enter fullscreen mode Exit fullscreen mode

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. → Get started in 2 minutes

Top comments (0)