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