PDFPipe

Use case

Payslip PDF API

Generate monthly payslips, salary slips, and tax statements as print-quality PDFs from HTML. Render one document or your entire headcount in a single call. Built for HR, payroll, and HRIS platforms with recurring volume.

One run, every employee

Render a payslip for your whole headcount in a single POST to /v1/pdf/batch. Documents render in parallel and each comes back with its own stable download URL. No per-employee round-trips, no queue to babysit.

Your layout, your branding

Send the HTML payslip your designers already built, with the company logo, fonts, color, and the earnings and deductions breakdown your finance team expects. A full browser renders it, so CSS Grid, Flexbox, and custom fonts come out exactly as previewed.

Built for monthly volume

Payslips recur every pay cycle for every employee, so cost predictability matters. Flat monthly plans, no per-call surprises, and renders that fail or time out are never counted against your quota.

Data stays in your stack

Salary figures live in your payroll system. You compute net pay, then send only the finished HTML to render. Nothing is logged from the document body, and stored PDFs expire automatically based on your plan retention.

Statements and letters too

The same endpoint covers annual tax statements, bonus and increment letters, and full-and-final settlements. One template engine, one API, every HR document your platform emits.

Accurate multi-page layout

Rendering runs under print emulation, so break-inside: avoid and @page rules are honored. Long deduction tables and year-to-date summaries do not split mid-row across pages.

1. Write the payslip template

Any HTML template engine works. Here is a Handlebars template that covers a typical monthly payslip: company header, employee identity, earnings, deductions, and net pay.

templates/payslip.html
<!-- templates/payslip.html -->
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8" />
  <style>
    body { font-family: system-ui, sans-serif; color: #1a1a1a; margin: 0; padding: 40px; font-size: 13px; }
    .header { display: flex; justify-content: space-between; align-items: start; border-bottom: 2px solid #1a1a1a; padding-bottom: 16px; }
    .company { font-size: 22px; font-weight: 700; }
    .doc-title { text-align: right; font-size: 13px; color: #666; }
    .doc-title strong { display: block; font-size: 16px; color: #1a1a1a; }
    .employee { display: flex; justify-content: space-between; margin: 28px 0; }
    .field-label { font-size: 11px; color: #888; text-transform: uppercase; letter-spacing: 0.04em; }
    .field-value { font-size: 14px; font-weight: 600; margin-top: 2px; }
    table { width: 100%; border-collapse: collapse; margin: 24px 0; }
    th { text-align: left; padding: 8px 12px; background: #f5f5f5; font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; }
    th.amount, td.amount { text-align: right; }
    td { padding: 10px 12px; border-bottom: 1px solid #eee; }
    .net-row td { font-weight: 700; font-size: 15px; border-top: 2px solid #1a1a1a; border-bottom: none; background: #fafafa; }
    .footer { font-size: 11px; color: #888; margin-top: 40px; border-top: 1px solid #eee; padding-top: 12px; }
  </style>
</head>
<body>
  <div class="header">
    <div class="company">Acme Corp</div>
    <div class="doc-title">
      <strong>Payslip</strong>
      <div>Pay period: {{payPeriod}}</div>
      <div>Pay date: {{payDate}}</div>
    </div>
  </div>

  <div class="employee">
    <div>
      <div class="field-label">Employee</div>
      <div class="field-value">{{employee.name}}</div>
    </div>
    <div>
      <div class="field-label">Employee ID</div>
      <div class="field-value">{{employee.id}}</div>
    </div>
    <div>
      <div class="field-label">Department</div>
      <div class="field-value">{{employee.department}}</div>
    </div>
  </div>

  <table>
    <thead>
      <tr><th>Earnings</th><th class="amount">Amount</th></tr>
    </thead>
    <tbody>
      <tr><td>Basic salary</td><td class="amount">{{basicSalary}}</td></tr>
      <tr><td>Allowances</td><td class="amount">{{allowances}}</td></tr>
    </tbody>
  </table>

  <table>
    <thead>
      <tr><th>Deductions</th><th class="amount">Amount</th></tr>
    </thead>
    <tbody>
      <tr><td>Total deductions</td><td class="amount">{{deductions}}</td></tr>
    </tbody>
    <tfoot>
      <tr class="net-row">
        <td>Net pay</td>
        <td class="amount">{{netPay}}</td>
      </tr>
    </tfoot>
  </table>

  <div class="footer">
    This payslip is computer generated and reflects earnings for the stated pay period.
    Keep it for your records. For queries, contact your HR department.
  </div>
</body>
</html>

2. Compile and POST (Node.js)

Compute the pay figures in your payroll logic, fill the template, and post the HTML. You get the PDF bytes back.

payslip.ts
// Node.js: compile the payslip template and call the API

import Handlebars from "handlebars";
import { readFileSync } from "fs";

const tmpl = Handlebars.compile(readFileSync("./templates/payslip.html", "utf-8"));

export async function generatePayslipPdf(data: {
  payPeriod: string;
  payDate: string;
  employee: { name: string; id: string; department: string };
  basicSalary: string;
  allowances: string;
  deductions: string;
  netPay: string;
}): Promise<Uint8Array> {
  const html = tmpl(data);

  const resp = await fetch("https://api.pdfpipe.xyz/v1/pdf", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.PDFPIPE_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      html,
      options: {
        format: "A4",
        margin: { top: "16mm", bottom: "16mm", left: "16mm", right: "16mm" },
        print_background: true,
      },
    }),
  });

  if (!resp.ok) throw new Error(`PDF generation failed: ${resp.status}`);
  return new Uint8Array(await resp.arrayBuffer());
}

2. Compile and POST (Python)

payslip.py
# Python: Jinja2 template + httpx

import httpx
from jinja2 import Environment, FileSystemLoader

jinja = Environment(loader=FileSystemLoader("templates"))

def generate_payslip_pdf(data: dict) -> bytes:
    html = jinja.get_template("payslip.html").render(**data)

    resp = httpx.post(
        "https://api.pdfpipe.xyz/v1/pdf",
        headers={"Authorization": f"Bearer {settings.PDFPIPE_API_KEY}"},
        json={
            "html": html,
            "options": {
                "format": "A4",
                "margin": {"top": "16mm", "bottom": "16mm", "left": "16mm", "right": "16mm"},
                "print_background": True,
            },
        },
        timeout=60.0,
    )
    resp.raise_for_status()
    return resp.content

Run payroll for every employee in one call

Payroll is inherently a batch job. Post a requests array to /v1/pdf/batch and every payslip renders in parallel. Each result carries a stored URL you can persist so the employee self-service portal always has a download link, without you holding the bytes.

payroll-run.ts
// Run payroll: render a payslip for every employee in one call

import Handlebars from "handlebars";
import { readFileSync } from "fs";

const tmpl = Handlebars.compile(readFileSync("./templates/payslip.html", "utf-8"));
const period = "2026-06"; // June 2026

// The payroll rows you already compute for the period
const employees = await db.payroll.forPeriod(period);
// each row: { name, id, department, basicSalary, allowances, deductions, netPay, payDate }

const requests = employees.map((e) => ({
  html: tmpl({
    payPeriod: "June 2026",
    payDate: e.payDate,
    employee: { name: e.name, id: e.id, department: e.department },
    basicSalary: e.basicSalary,
    allowances: e.allowances,
    deductions: e.deductions,
    netPay: e.netPay,
  }),
  filename: `payslip-${e.id}-${period}.pdf`,
  options: { format: "A4", print_background: true },
}));

const resp = await fetch("https://api.pdfpipe.xyz/v1/pdf/batch", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.PDFPIPE_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ requests, options: { format: "A4" } }),
});

if (!resp.ok) throw new Error(`Batch failed: ${resp.status}`);
const { results, usage } = await resp.json();

// Persist each stored URL so the employee self-service portal has a download link
await db.payslips.bulkCreate(
  results
    .filter((r) => r.status === "ok")
    .map((r, idx) => ({
      employee_id: employees[idx].id,
      period,
      pdf_id: r.id,
      pdf_url: r.url,
      expires_at: new Date(r.expires),
    }))
);

console.log(`Payroll ${period}: ${results.length} payslips, ${usage.total_this_month}/${usage.limit} used`);

Pricing

PlanPricePayslips / monthOverage
HobbyFree500Not available
Starter$193,000Not available
Growth$4915,000Not available
Scale$14950,000Not available
Business$499100,000Contact sales

Start generating payslips

500 documents a month free. No credit card. Key issued instantly after signup. Batch your whole headcount on a single call from day one.

Related guide

PDF in Python →

Related guide

PDF in Django →

Related guide

PDF in Node.js →