DEV Community

Cover image for PDF Generation API: Invoices, Reports, Documents
APIVerve
APIVerve

Posted on • Originally published at blog.apiverve.com

PDF Generation API: Invoices, Reports, Documents

I spent three days trying to generate PDFs in a Node.js app.

The first approach was puppeteer. Installing it added 300MB to my container because it bundles Chromium. Fine. Then it worked locally but crashed in production with cryptic errors about missing libraries. Fixed those. Then the memory usage: each PDF generation spun up a browser instance, consumed 200MB, and occasionally just... didn't release it. I woke up to a dead server that had run out of RAM generating invoices.

The second approach was a "lightweight" PDF library. No browser dependency, small footprint. Perfect. Except it couldn't render flexbox. Or modern CSS. The invoice templates I'd designed in CSS had to be completely rewritten using absolute positioning. It looked like 2005.

Then I found PDF APIs. Send HTML, get a PDF. Someone else deals with headless Chrome, memory management, and rendering complexity. I get a document.

That three-day project took 20 minutes.

The Simplest Version

const response = await fetch('https://api.apiverve.com/v1/htmltopdf', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'x-api-key': process.env.APIVERVE_KEY
  },
  body: JSON.stringify({
    html: '<h1>Hello World</h1><p>This is a PDF.</p>'
  })
});

const { data } = await response.json();
console.log(data.downloadURL);
// Returns a signed URL to download the generated PDF
Enter fullscreen mode Exit fullscreen mode

That's it. Your HTML goes in, a PDF URL comes out. The URL is signed and temporary—download it, store it, or redirect users to it directly.

Why Not Just Use Browser Print-to-PDF?

I get this question a lot. "Can't I just use window.print() and tell users to save as PDF?"

Sure, if you want:

  • Different results on every browser and OS
  • Users who don't understand "Destination: Save as PDF"
  • Broken layouts from print stylesheets you didn't write
  • No way to automate anything

Server-side PDF generation gives you:

  • Identical output every time
  • Automated batch generation
  • No user interaction required
  • Consistent branding regardless of client

For one-off documents a user wants to print, window.print() is fine. For anything automated—invoices, receipts, reports, contracts—you need server-side generation.

Building a Real Invoice System

Let me show you a complete invoice implementation. This is what I wish I'd had when I started.

function generateInvoiceHTML(invoice) {
  const lineItems = invoice.items.map(item => `
    <tr>
      <td>${item.description}</td>
      <td class="right">${item.quantity}</td>
      <td class="right">$${item.unitPrice.toFixed(2)}</td>
      <td class="right">$${(item.quantity * item.unitPrice).toFixed(2)}</td>
    </tr>
  `).join('');

  return `
<!DOCTYPE html>
<html>
<head>
  <style>
    * { box-sizing: border-box; }
    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
      color: #1a1a1a;
      line-height: 1.5;
      padding: 40px;
      max-width: 800px;
    }

    .header {
      display: flex;
      justify-content: space-between;
      align-items: flex-start;
      margin-bottom: 40px;
      padding-bottom: 20px;
      border-bottom: 2px solid #e5e5e5;
    }

    .logo {
      font-size: 28px;
      font-weight: 700;
      color: #2563eb;
    }

    .invoice-title {
      text-align: right;
    }

    .invoice-title h1 {
      margin: 0;
      font-size: 32px;
      color: #64748b;
      font-weight: 300;
      letter-spacing: 2px;
    }

    .invoice-number {
      font-size: 14px;
      color: #64748b;
    }

    .parties {
      display: flex;
      gap: 60px;
      margin-bottom: 40px;
    }

    .party-box h3 {
      font-size: 11px;
      text-transform: uppercase;
      letter-spacing: 1px;
      color: #64748b;
      margin: 0 0 8px 0;
    }

    .party-box p {
      margin: 0;
      font-size: 14px;
    }

    .party-box .name {
      font-weight: 600;
      font-size: 16px;
    }

    .meta {
      display: flex;
      gap: 40px;
      margin-bottom: 40px;
      padding: 20px;
      background: #f8fafc;
      border-radius: 8px;
    }

    .meta-item {
      font-size: 13px;
    }

    .meta-item .label {
      color: #64748b;
      font-size: 11px;
      text-transform: uppercase;
      letter-spacing: 0.5px;
    }

    .meta-item .value {
      font-weight: 600;
      margin-top: 4px;
    }

    table {
      width: 100%;
      border-collapse: collapse;
      margin-bottom: 24px;
    }

    th {
      text-align: left;
      padding: 12px 8px;
      font-size: 11px;
      text-transform: uppercase;
      letter-spacing: 0.5px;
      color: #64748b;
      border-bottom: 2px solid #e5e5e5;
    }

    td {
      padding: 16px 8px;
      border-bottom: 1px solid #f1f5f9;
      font-size: 14px;
    }

    .right { text-align: right; }

    .totals {
      width: 280px;
      margin-left: auto;
    }

    .totals tr td {
      border: none;
      padding: 8px;
    }

    .totals .label { color: #64748b; }
    .totals .value { text-align: right; font-weight: 500; }

    .totals .total-row td {
      padding-top: 16px;
      border-top: 2px solid #e5e5e5;
      font-size: 18px;
    }

    .totals .total-row .value {
      color: #2563eb;
      font-weight: 700;
    }

    .notes {
      margin-top: 60px;
      padding-top: 20px;
      border-top: 1px solid #e5e5e5;
      font-size: 12px;
      color: #64748b;
    }

    .payment-info {
      margin-top: 24px;
      padding: 16px;
      background: #f0fdf4;
      border-radius: 8px;
      font-size: 13px;
    }

    .payment-info strong { color: #166534; }
  </style>
</head>
<body>
  <div class="header">
    <div class="logo">${invoice.company.name}</div>
    <div class="invoice-title">
      <h1>INVOICE</h1>
      <div class="invoice-number">#${invoice.number}</div>
    </div>
  </div>

  <div class="parties">
    <div class="party-box">
      <h3>From</h3>
      <p class="name">${invoice.company.name}</p>
      <p>${invoice.company.address}</p>
      <p>${invoice.company.city}, ${invoice.company.state} ${invoice.company.zip}</p>
      <p>${invoice.company.email}</p>
    </div>

    <div class="party-box">
      <h3>Bill To</h3>
      <p class="name">${invoice.customer.name}</p>
      <p>${invoice.customer.address}</p>
      <p>${invoice.customer.city}, ${invoice.customer.state} ${invoice.customer.zip}</p>
      <p>${invoice.customer.email}</p>
    </div>
  </div>

  <div class="meta">
    <div class="meta-item">
      <div class="label">Invoice Date</div>
      <div class="value">${invoice.date}</div>
    </div>
    <div class="meta-item">
      <div class="label">Due Date</div>
      <div class="value">${invoice.dueDate}</div>
    </div>
    <div class="meta-item">
      <div class="label">Payment Terms</div>
      <div class="value">${invoice.terms || 'Net 30'}</div>
    </div>
  </div>

  <table>
    <thead>
      <tr>
        <th>Description</th>
        <th class="right">Qty</th>
        <th class="right">Rate</th>
        <th class="right">Amount</th>
      </tr>
    </thead>
    <tbody>
      ${lineItems}
    </tbody>
  </table>

  <table class="totals">
    <tr>
      <td class="label">Subtotal</td>
      <td class="value">$${invoice.subtotal.toFixed(2)}</td>
    </tr>
    ${invoice.discount ? `
    <tr>
      <td class="label">Discount (${invoice.discountPercent}%)</td>
      <td class="value">-$${invoice.discount.toFixed(2)}</td>
    </tr>
    ` : ''}
    <tr>
      <td class="label">Tax (${invoice.taxRate}%)</td>
      <td class="value">$${invoice.tax.toFixed(2)}</td>
    </tr>
    <tr class="total-row">
      <td class="label">Total Due</td>
      <td class="value">$${invoice.total.toFixed(2)}</td>
    </tr>
  </table>

  ${invoice.paymentInfo ? `
  <div class="payment-info">
    <strong>Payment Instructions:</strong><br>
    ${invoice.paymentInfo}
  </div>
  ` : ''}

  <div class="notes">
    <strong>Notes:</strong><br>
    ${invoice.notes || 'Thank you for your business. Payment is due within ' + (invoice.terms || '30 days') + '.'}
  </div>
</body>
</html>
`;
}
Enter fullscreen mode Exit fullscreen mode

That's a production-quality invoice template. Modern styling, responsive layout, professional appearance. Now use it:

async function createInvoicePDF(invoiceData) {
  const html = generateInvoiceHTML(invoiceData);

  const response = await fetch('https://api.apiverve.com/v1/htmltopdf', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'x-api-key': process.env.APIVERVE_KEY
    },
    body: JSON.stringify({
      html,
      marginTop: 0.5,
      marginRight: 0.5,
      marginBottom: 0.5,
      marginLeft: 0.5
    })
  });

  const { data } = await response.json();
  return data.downloadURL;
}

// Usage
const pdfUrl = await createInvoicePDF({
  company: {
    name: 'Acme Development LLC',
    address: '100 Innovation Drive',
    city: 'San Francisco',
    state: 'CA',
    zip: '94102',
    email: 'billing@acmedev.io'
  },
  customer: {
    name: 'Widgets Inc.',
    address: '500 Commerce Street',
    city: 'Austin',
    state: 'TX',
    zip: '78701',
    email: 'accounts@widgets.co'
  },
  number: 'INV-2026-0042',
  date: 'January 15, 2026',
  dueDate: 'February 14, 2026',
  terms: 'Net 30',
  items: [
    { description: 'Web Application Development', quantity: 80, unitPrice: 150 },
    { description: 'API Integration', quantity: 24, unitPrice: 175 },
    { description: 'Database Optimization', quantity: 16, unitPrice: 200 },
    { description: 'Monthly Hosting', quantity: 1, unitPrice: 299 }
  ],
  subtotal: 19499,
  taxRate: 0,
  tax: 0,
  total: 19499,
  paymentInfo: 'Bank: First National Bank | Account: 1234567890 | Routing: 021000021',
  notes: 'Thank you for your business. Please reference invoice number with payment.'
});
Enter fullscreen mode Exit fullscreen mode

Markdown to PDF: The Simpler Option

For text-heavy documents where you don't need custom styling, Markdown is faster:

const markdown = `
# Q4 2025 Performance Report

## Executive Summary

Strong quarter across all metrics. Revenue exceeded targets by 12%, user growth
hit 34% YoY, and churn decreased to all-time low.

## Key Metrics

| Metric | Target | Actual | Status |
|--------|--------|--------|--------|
| Revenue | $2.4M | $2.69M | ✓ |
| New Users | 15,000 | 18,420 | ✓ |
| Churn Rate | 2.5% | 1.8% | ✓ |
| NPS Score | 45 | 52 | ✓ |

## Highlights

### Product
- Launched v3.0 with new dashboard
- API response time improved 40%
- Mobile app downloads up 67%

### Growth
- Enterprise segment grew 45%
- Partnership with BigCorp signed
- International expansion to 3 new markets

### Operations
- Support ticket volume down 23%
- Infrastructure costs reduced 15%
- Zero downtime incidents

## Recommendations for Q1

1. **Double down on enterprise** - Highest margin segment
2. **Launch referral program** - Capitalize on high NPS
3. **Expand API offerings** - Customer requests indicate demand

## Conclusion

Exceptional quarter. Team executed well across all departments. Well-positioned
for aggressive 2026 targets.
`;

const response = await fetch('https://api.apiverve.com/v1/markdowntopdf', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'x-api-key': process.env.APIVERVE_KEY
  },
  body: JSON.stringify({ markdown })
});

const { data } = await response.json();
// Clean, formatted PDF with proper headings and tables
Enter fullscreen mode Exit fullscreen mode

Tables, lists, headers, emphasis—all rendered properly. For internal reports, documentation, or any structured text, Markdown-to-PDF is the path of least resistance.

Receipts and Thermal Printer Style

E-commerce, POS systems, and order confirmations need a different format:

function generateReceiptHTML(receipt) {
  return `
<!DOCTYPE html>
<html>
<head>
  <style>
    body {
      font-family: 'Courier New', monospace;
      font-size: 12px;
      width: 280px;
      padding: 16px;
      line-height: 1.4;
    }
    .center { text-align: center; }
    .bold { font-weight: bold; }
    .divider {
      border: none;
      border-top: 1px dashed #000;
      margin: 12px 0;
    }
    .row {
      display: flex;
      justify-content: space-between;
    }
    .item-row {
      display: flex;
      justify-content: space-between;
      margin: 4px 0;
    }
    .item-name { max-width: 180px; }
    .total-line {
      font-weight: bold;
      font-size: 14px;
      border-top: 2px solid #000;
      padding-top: 8px;
      margin-top: 8px;
    }
    .footer {
      margin-top: 20px;
      text-align: center;
      font-size: 10px;
      color: #666;
    }
  </style>
</head>
<body>
  <div class="center bold" style="font-size: 16px;">
    ${receipt.storeName}
  </div>
  <div class="center">${receipt.storeAddress}</div>
  <div class="center">${receipt.storePhone}</div>

  <hr class="divider">

  <div class="center">
    Receipt #${receipt.receiptNumber}<br>
    ${receipt.date} at ${receipt.time}<br>
    Cashier: ${receipt.cashier || 'Self'}
  </div>

  <hr class="divider">

  ${receipt.items.map(item => `
    <div class="item-row">
      <span class="item-name">${item.name}${item.quantity > 1 ? ` x${item.quantity}` : ''}</span>
      <span>$${(item.price * item.quantity).toFixed(2)}</span>
    </div>
  `).join('')}

  <hr class="divider">

  <div class="row">
    <span>Subtotal</span>
    <span>$${receipt.subtotal.toFixed(2)}</span>
  </div>
  <div class="row">
    <span>Tax</span>
    <span>$${receipt.tax.toFixed(2)}</span>
  </div>

  <div class="row total-line">
    <span>TOTAL</span>
    <span>$${receipt.total.toFixed(2)}</span>
  </div>

  <hr class="divider">

  <div class="row">
    <span>${receipt.paymentMethod}</span>
    <span>$${receipt.amountPaid.toFixed(2)}</span>
  </div>
  ${receipt.change > 0 ? `
  <div class="row">
    <span>Change</span>
    <span>$${receipt.change.toFixed(2)}</span>
  </div>
  ` : ''}

  <div class="footer">
    Thank you for shopping with us!<br>
    ${receipt.returnPolicy || 'Returns accepted within 30 days with receipt.'}
  </div>
</body>
</html>
`;
}
Enter fullscreen mode Exit fullscreen mode

Generate with minimal margins for that authentic thermal printer look:

const { data } = await fetch('https://api.apiverve.com/v1/htmltopdf', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'x-api-key': process.env.APIVERVE_KEY
  },
  body: JSON.stringify({
    html: generateReceiptHTML(receiptData),
    marginTop: 0.2,
    marginRight: 0.2,
    marginBottom: 0.2,
    marginLeft: 0.2
  })
}).then(r => r.json());
Enter fullscreen mode Exit fullscreen mode

What to Do with the PDF

The API returns a downloadURL. Here's what you can do with it:

Redirect user to download:

// Express.js
app.get('/invoice/:id/download', async (req, res) => {
  const invoice = await getInvoice(req.params.id);
  const pdfUrl = await createInvoicePDF(invoice);
  res.redirect(pdfUrl);
});
Enter fullscreen mode Exit fullscreen mode

Email as attachment:

const pdfUrl = await createInvoicePDF(invoice);
const pdfResponse = await fetch(pdfUrl);
const pdfBuffer = Buffer.from(await pdfResponse.arrayBuffer());

await sendEmail({
  to: invoice.customer.email,
  subject: `Invoice ${invoice.number} from ${invoice.company.name}`,
  html: '<p>Please find your invoice attached.</p>',
  attachments: [{
    filename: `invoice-${invoice.number}.pdf`,
    content: pdfBuffer
  }]
});
Enter fullscreen mode Exit fullscreen mode

Store in cloud storage:

const pdfUrl = await createInvoicePDF(invoice);
const pdfResponse = await fetch(pdfUrl);
const pdfBuffer = Buffer.from(await pdfResponse.arrayBuffer());

const s3Key = `invoices/${invoice.number}.pdf`;
await s3.putObject({
  Bucket: 'my-invoices',
  Key: s3Key,
  Body: pdfBuffer,
  ContentType: 'application/pdf'
}).promise();
Enter fullscreen mode Exit fullscreen mode

Stream directly to user:

app.get('/invoice/:id/pdf', async (req, res) => {
  const invoice = await getInvoice(req.params.id);
  const pdfUrl = await createInvoicePDF(invoice);

  const pdfResponse = await fetch(pdfUrl);

  res.setHeader('Content-Type', 'application/pdf');
  res.setHeader('Content-Disposition', `attachment; filename="invoice-${invoice.number}.pdf"`);

  // Stream the response (Node 18+)
  const reader = pdfResponse.body.getReader();
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    res.write(value);
  }
  res.end();
});
Enter fullscreen mode Exit fullscreen mode

Batch Generation

Monthly invoices, bulk reports, mass mailers—sometimes you need lots of PDFs.

async function generateBatchPDFs(items, generator, concurrency = 5) {
  const results = [];

  // Process in chunks to avoid overwhelming the API
  for (let i = 0; i < items.length; i += concurrency) {
    const chunk = items.slice(i, i + concurrency);

    const chunkResults = await Promise.all(
      chunk.map(async item => {
        try {
          const html = generator(item);
          const response = await fetch('https://api.apiverve.com/v1/htmltopdf', {
            method: 'POST',
            headers: {
              'Content-Type': 'application/json',
              'x-api-key': process.env.APIVERVE_KEY
            },
            body: JSON.stringify({ html })
          });

          const { data } = await response.json();
          return { id: item.id, success: true, url: data.downloadURL };

        } catch (err) {
          return { id: item.id, success: false, error: err.message };
        }
      })
    );

    results.push(...chunkResults);

    // Brief pause between chunks
    if (i + concurrency < items.length) {
      await new Promise(r => setTimeout(r, 200));
    }
  }

  return results;
}

// Generate all monthly invoices
const invoices = await getMonthlyInvoices('2026-01');
const pdfs = await generateBatchPDFs(invoices, generateInvoiceHTML);

console.log(`Generated ${pdfs.filter(p => p.success).length} PDFs`);
Enter fullscreen mode Exit fullscreen mode

Common Mistakes

Forgetting that CSS is different for print. Web CSS and print CSS aren't identical. Things to watch:

  • box-shadow doesn't print well
  • Gradients can be problematic
  • Some fonts don't embed properly

Not handling page breaks. Long content needs explicit page breaks:

.page-break { page-break-before: always; }
.avoid-break { page-break-inside: avoid; }
Enter fullscreen mode Exit fullscreen mode

Ignoring timeouts. PDF generation is slower than typical API calls. Set appropriate timeouts (15-30 seconds minimum).

Assuming URLs will work. If your HTML references external images via URL, make sure they're publicly accessible. Or better, embed images as base64:

const imageBase64 = Buffer.from(imageBuffer).toString('base64');
const imgTag = `<img src="data:image/png;base64,${imageBase64}">`;
Enter fullscreen mode Exit fullscreen mode

Not testing on actual printers. Your PDF will look great on screen. Will it print correctly? Test it. Margins matter.

The Economics

PDF generation costs 1 credit per document.

On the Starter plan ({{plan.starter.price}}/month, {{plan.starter.calls}} credits), that's thousands of PDFs per month. If you're sending monthly invoices, that's affordable pricing for professional document generation.

Compare to:

  • Running your own puppeteer infrastructure: $50-200/month for dedicated servers with enough RAM
  • Headless Chrome services: $0.01-0.05 per PDF, similar pricing but you manage nothing

For most businesses generating fewer than 50,000 documents/month, the API is cheaper and infinitely simpler.


PDF generation used to be a weekend project that turned into a two-week infrastructure nightmare. Headless Chrome. Memory management. Rendering inconsistencies. Dependencies that broke on updates.

Now it's an API call. HTML in, PDF out. Someone else handles the browser, the rendering, the infrastructure. You focus on the content.

The HTML to PDF and Markdown to PDF APIs use the same authentication as everything else. Combine them with QR Code Generator for scannable invoices or Barcode Generator for shipping labels.

Get your API key and start generating documents that look professional without the infrastructure headaches.


Originally published at APIVerve Blog

Top comments (0)