Back to Blog
Guide
February 26, 2026 · 4 min read
How to Generate PDF Purchase Orders Automatically
Generate branded PDF purchase orders when a vendor is selected, a budget is approved, or a procurement workflow triggers — no Word templates, no manual fills, no emailing PDFs by hand.
Procurement teams create POs in spreadsheets, copy them to Word templates, export to PDF, and email them to vendors. Every PO takes 10–15 minutes. Multiply by 50 POs a month and it's a half-time job.
Here's how to generate a PO the moment a purchase request is approved — triggered by your ERP, your approval workflow, or a direct API call.
Purchase order HTML template
function renderPurchaseOrderHtml({
poNumber,
poDate,
deliveryDate,
vendor,
shipTo,
lineItems,
paymentTerms,
notes,
currency = "USD",
}) {
const fmt = (n) =>
new Intl.NumberFormat("en-US", { style: "currency", currency }).format(n);
const subtotal = lineItems.reduce((s, i) => s + i.qty * i.unitPrice, 0);
const tax = subtotal * 0.0; // adjust for your jurisdiction
const total = subtotal + tax;
const rows = lineItems
.map(
(item) => `
<tr>
<td>${item.sku ?? ""}</td>
<td>${item.description}</td>
<td style="text-align:right">${item.qty}</td>
<td style="text-align:right">${fmt(item.unitPrice)}</td>
<td style="text-align:right">${fmt(item.qty * item.unitPrice)}</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: 800px; margin: 0 auto; padding: 40px; font-size: 13px; }
.header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 32px; padding-bottom: 20px; border-bottom: 3px solid #111; }
.logo { font-size: 22px; font-weight: 800; }
.po-meta { text-align: right; }
.po-meta h1 { font-size: 26px; font-weight: 900; color: #111; margin: 0 0 4px; }
.po-number { font-size: 14px; color: #666; }
.addresses { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 24px; margin-bottom: 28px; }
.address-label { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #999; margin-bottom: 6px; }
.address-block p { line-height: 1.6; }
.meta-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; background: #f8f8f8; padding: 14px 16px; border-radius: 6px; margin-bottom: 24px; }
.meta-item .label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.8px; color: #999; }
.meta-item .value { font-weight: 600; margin-top: 2px; }
table { width: 100%; border-collapse: collapse; margin-bottom: 16px; }
thead tr { background: #111; color: white; }
thead td { padding: 10px 8px; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; }
tbody tr:nth-child(even) { background: #f9f9f9; }
tbody td { padding: 10px 8px; border-bottom: 1px solid #eee; }
.totals { margin-left: auto; width: 260px; }
.totals table { font-size: 13px; }
.totals td { padding: 6px 0; border: none; }
.totals td:last-child { text-align: right; font-variant-numeric: tabular-nums; }
.total-row td { font-weight: 800; font-size: 16px; border-top: 2px solid #111; padding-top: 10px; }
.footer-notes { margin-top: 32px; padding-top: 16px; border-top: 1px solid #eee; font-size: 12px; color: #666; }
.auth-box { margin-top: 40px; display: grid; grid-template-columns: 1fr 1fr; gap: 48px; }
.sig-line { border-bottom: 1px solid #999; height: 40px; margin-bottom: 4px; }
.sig-label { font-size: 11px; color: #999; }
</style>
</head>
<body>
<div class="header">
<div>
<div class="logo">YourCompany</div>
<div style="color:#666;font-size:12px;margin-top:4px">123 Main St, San Francisco CA 94105</div>
</div>
<div class="po-meta">
<h1>PURCHASE ORDER</h1>
<div class="po-number">PO# ${poNumber}</div>
</div>
</div>
<div class="addresses">
<div class="address-block">
<div class="address-label">Vendor</div>
<p><strong>${vendor.name}</strong><br>
${vendor.address ?? ""}<br>
${vendor.contact ? `Attn: ${vendor.contact}` : ""}
${vendor.email ? `<br>${vendor.email}` : ""}</p>
</div>
<div class="address-block">
<div class="address-label">Ship To</div>
<p><strong>${shipTo.name}</strong><br>
${shipTo.address ?? ""}<br>
${shipTo.contact ? `Attn: ${shipTo.contact}` : ""}</p>
</div>
</div>
<div class="meta-row">
<div class="meta-item"><div class="label">PO Date</div><div class="value">${poDate}</div></div>
<div class="meta-item"><div class="label">Delivery Date</div><div class="value">${deliveryDate}</div></div>
<div class="meta-item"><div class="label">Payment Terms</div><div class="value">${paymentTerms}</div></div>
<div class="meta-item"><div class="label">Currency</div><div class="value">${currency}</div></div>
</div>
<table>
<thead>
<tr>
<td>SKU</td><td>Description</td>
<td style="text-align:right">Qty</td>
<td style="text-align:right">Unit Price</td>
<td style="text-align:right">Amount</td>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
<div class="totals">
<table>
<tr><td>Subtotal</td><td>${fmt(subtotal)}</td></tr>
${tax > 0 ? `<tr><td>Tax</td><td>${fmt(tax)}</td></tr>` : ""}
<tr class="total-row"><td>Total</td><td>${fmt(total)}</td></tr>
</table>
</div>
${notes ? `<div class="footer-notes"><strong>Notes:</strong> ${notes}</div>` : ""}
<div class="auth-box">
<div>
<div class="sig-line"></div>
<div class="sig-label">Authorized by — Purchasing Department</div>
</div>
<div>
<div class="sig-line"></div>
<div class="sig-label">Vendor acknowledgement</div>
</div>
</div>
</body>
</html>`;
}
Generate PO on approval webhook
app.post("/webhooks/approval/purchase-request", async (req, res) => {
res.json({ ok: true });
const { requestId, approvedBy } = req.body;
const request = await db.purchaseRequests.findById(requestId, {
include: { vendor: true, lineItems: true, requester: true },
});
const poNumber = await generatePONumber(); // e.g. PO-2026-00142
const html = renderPurchaseOrderHtml({
poNumber,
poDate: new Date().toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }),
deliveryDate: request.requiredBy,
vendor: request.vendor,
shipTo: { name: "YourCompany — Receiving", address: "123 Main St, San Francisco CA 94105" },
lineItems: request.lineItems,
paymentTerms: request.vendor.paymentTerms ?? "Net 30",
notes: request.notes,
});
const res2 = 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 }),
});
const pdf = Buffer.from(await res2.arrayBuffer());
// Store + email vendor
await db.purchaseOrders.create({ data: { poNumber, requestId, approvedBy } });
await uploadToS3(pdf, `purchase-orders/${poNumber}.pdf`);
await emailPOToVendor({ vendor: request.vendor, pdf, poNumber });
// Email confirmation to requester
await emailPOConfirmation({ requester: request.requester, poNumber });
});
Jira Service Management integration
// Triggered when a Jira "Purchase Request" issue moves to "Approved"
app.post("/webhooks/jira", async (req, res) => {
const { issue, changelog } = req.body;
const transition = changelog?.items?.find(
(c) => c.field === "status" && c.toString === "Approved"
);
if (!transition) return res.json({ ok: true });
const fields = issue.fields;
const html = renderPurchaseOrderHtml({
poNumber: `PO-${issue.key}`,
poDate: new Date().toLocaleDateString(),
deliveryDate: fields.duedate ?? "TBD",
vendor: { name: fields.customfield_vendor, email: fields.customfield_vendor_email },
shipTo: { name: "YourCompany", address: fields.customfield_ship_to },
lineItems: JSON.parse(fields.customfield_line_items ?? "[]"),
paymentTerms: fields.customfield_payment_terms ?? "Net 30",
notes: fields.description,
});
const pdfRes = 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 }),
});
const pdf = Buffer.from(await pdfRes.arrayBuffer());
// Attach PDF back to Jira issue
await jira.addAttachment(issue.id, pdf, `${issue.key}-purchase-order.pdf`);
res.json({ ok: true });
});
No Word templates. No manual fills. One approved purchase request = one API call = vendor inbox updated automatically.
Try it free
100 requests/month, no credit card required. OG images, screenshots, PDFs, and video — one API.
Get API Key — Free