Invoice PDF Generation API: Rails, Laravel, Node

Generate invoice and receipt PDFs from your HTML template: page numbers, tables that break cleanly across pages, and job code for Rails, Laravel and Node.

By Simon Chiu · Updated

Invoices are the PDF every SaaS product ends up needing, and the one that gets checked line by line by an accountant. They have to paginate cleanly, carry page numbers, keep the totals together and look the same next year as today. Render the invoice with the HTML template your app already has, send that HTML to RenderScreenshot with A4 or Letter paper and a footer template, and store the PDF in a background job. This page covers the requests, the CSS that controls page breaks, and job code for Rails, Laravel and Node.

It sits with our other use cases. The HTML to PDF API page covers the PDF options in general, including the header and footer rules that every document needs.

Four requests for billing documents

Each body goes to POST https://api.renderscreenshot.com/v1/screenshot.

Document Body
A4 invoice from your HTML, "Page 1 of 2" footer {"html": "<!doctype html>...", "output": {"format": "pdf", "filename": "INV-2048"}, "pdf": {"paper": "a4", "margin": "16mm", "margin_bottom": "22mm", "header": "<span></span>", "footer": "<div style='font:9px Helvetica,Arial,sans-serif;width:100%;text-align:center'>Page <span class='pageNumber'></span> of <span class='totalPages'></span></div>"}}
Letter invoice from a page in your app {"url": "https://billing.example.com/invoices/2048/print", "network": {"auth": {"type": "bearer", "token": "<short-lived token>"}}, "output": {"format": "pdf"}, "pdf": {"paper": "letter", "margin": "0.75in"}}
80 mm receipt {"html": "...", "output": {"format": "pdf"}, "pdf": {"width": "80mm", "height": "200mm", "margin": "4mm"}}
One-page credit note {"html": "...", "output": {"format": "pdf"}, "pdf": {"paper": "a4", "margin": "16mm", "fit_one_page": true}}

Notes on these bodies:

  • HTML input keeps private data private. The renderer does not need to sign in to your billing pages. If the template links a stylesheet or logo with a relative path, add html_base_url so it resolves (content input).
  • The bearer token goes only to the invoice's origin. network.auth is never sent to third-party hosts the page loads (network options). Use a token that can read one invoice and expires in minutes.
  • The empty <span></span> header stops Chrome from printing its default date and title header when you only want a footer.
  • fit_one_page scales the content down until it fits on one sheet. Use it for short documents only; a 40-line invoice scaled to one page is unreadable.

Fit rules: make the table break where you want

A 48-line invoice does not fit on one page, and Chrome decides where to cut. We printed such an invoice (A4, 16 mm margins) in Chromium through Puppeteer 25.14.0 and Playwright 1.64.1 on 2026-10-18. It took two pages. These CSS rules gave a clean result:

thead  { display: table-header-group; }  /* the default; keeps the header repeating if a reset changed it */
tr     { break-inside: avoid; }          /* never split one line item */
.totals { break-inside: avoid; }         /* keep subtotal, tax and total together */
.n     { font-variant-numeric: tabular-nums; text-align: right; }  /* amounts line up */
  • Header repeat: the column header printed at the top of both pages.
  • Totals block: it stayed in one piece under the last line item on page 2.
  • Footer font: our first footer template had a font-size but no font-family, and the PDF listed Times-Roman as an embedded font: the page numbers were the only serif text on the invoice. Put both properties inline in the template.

Two more rules from practice. Put the payment instructions in the totals block, not in the page footer, because the footer repeats on every page. And use @media print styles in the template only if you also set browser.media to print; by default the renderer uses the screen stylesheet (print media screenshots).

How it works

sequenceDiagram
    participant App as Your app
    participant Job as Background job
    participant API as RenderScreenshot
    participant Store as Your storage
    App->>Job: invoice finalized (id 2048)
    Job->>Job: render invoice template to HTML
    Job->>API: POST /v1/screenshot (html, format pdf, pdf options)
    API-->>Job: PDF bytes + X-Cache-URL
    Job->>Store: save INV-2048.pdf
    Job->>App: mark invoice ready, send email

Render in a job, not in the web request. A render can take seconds when the template loads fonts, and the API returns retryable errors such as timeout that a job runner retries for you. The background jobs guide has retry settings.

Keep the PDF in your own storage. Invoices are records you must keep for years; the X-Cache-URL link is public and expires with the cache TTL.

Rails: Active Job and Active Storage

# app/jobs/invoice_pdf_job.rb
class InvoicePdfJob < ApplicationJob
  queue_as :default
  retry_on RenderScreenshot::Error, wait: :polynomially_longer, attempts: 5

  FOOTER = '<div style="font:9px Helvetica,Arial,sans-serif;width:100%;text-align:center">' \
           'Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>'

  def perform(invoice)
    html = ApplicationController.render(template: 'invoices/pdf', layout: 'pdf', assigns: { invoice: invoice })
    client = RenderScreenshot.client(ENV.fetch('RS_API_KEY'))
    pdf = client.take(
      RenderScreenshot::TakeOptions.html(html)
        .format('pdf')
        .pdf_paper_size('a4')
        .pdf_margin('16mm')
        .pdf_print_background
        .pdf_header('<span></span>')
        .pdf_footer(FOOTER)
    )
    invoice.pdf.attach(io: StringIO.new(pdf), filename: "#{invoice.number}.pdf", content_type: 'application/pdf')
  end
end

ERB escapes <%= invoice.customer_name %> by default. Keep it that way: customer names and line descriptions are user input, and raw or html_safe on them lets markup into the renderer.

Laravel: a queued job

<?php
// app/Jobs/RenderInvoicePdf.php
namespace App\Jobs;

use App\Models\Invoice;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Storage;
use RenderScreenshot\Client;
use RenderScreenshot\TakeOptions;

class RenderInvoicePdf implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 5;
    public array $backoff = [10, 30, 90];

    public function __construct(public Invoice $invoice) {}

    public function handle(): void
    {
        $html = view('invoices.pdf', ['invoice' => $this->invoice])->render();
        $client = new Client(config('services.renderscreenshot.key'));

        $pdf = $client->take(
            TakeOptions::html($html)
                ->format('pdf')
                ->pdfPaperSize('a4')
                ->pdfMargin('16mm')
                ->pdfPrintBackground()
                ->pdfHeader('<span></span>')
                ->pdfFooter('<div style="font:9px Helvetica,Arial,sans-serif;width:100%;text-align:center">Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>')
        );

        Storage::disk('s3')->put("invoices/{$this->invoice->number}.pdf", $pdf);
    }
}

Blade's {{ }} escapes output; {!! !!} does not. Use the first for every customer field.

Node: an Express download route

For a "Download PDF" button on an invoice the user is already allowed to see:

// Node.js 20+: npm install express renderscreenshot
import express from 'express';
import { Client, TakeOptions, RenderScreenshotError } from 'renderscreenshot';
import { renderInvoiceHtml, findInvoiceForUser } from './invoices.js'; // your code

const client = new Client(process.env.RS_API_KEY);
const app = express();

app.get('/invoices/:id.pdf', async (req, res, next) => {
  try {
    const invoice = await findInvoiceForUser(req.params.id, req.user); // authorization first
    if (!invoice) return res.sendStatus(404);
    const pdf = await client.take(
      TakeOptions.html(renderInvoiceHtml(invoice)) // the template escapes every field
        .format('pdf')
        .pdfPaperSize('a4')
        .pdfMargin('16mm')
        .pdfPrintBackground()
        .pdfHeader('<span></span>')
        .pdfFooter('<div style="font:9px Helvetica,Arial,sans-serif;width:100%;text-align:center">Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>')
    );
    res.type('application/pdf').attachment(`${invoice.number}.pdf`).send(pdf);
  } catch (err) {
    if (err instanceof RenderScreenshotError && err.retryable) return res.status(503).send('Try again in a moment');
    next(err);
  }
});

app.listen(3000);

For invoices that do not change after they are issued, render once and serve the stored file instead of calling the API on every click. If you render on demand, cache.available: true makes an identical request within the TTL return the earlier file for 0 credits (caching).

Settings, and why

Setting Value Reason
pdf.paper a4 or letter Match the customer's country; Letter is the default
pdf.margin_bottom at least 20 mm with a footer The footer prints inside the margin
pdf.print_background true (default) Zebra rows and the header band are backgrounds
output.filename the invoice number The name the browser offers on save
cache.ttl 3600 The shortest life for the public CDN copy

What it costs

One invoice is one credit, whatever its page count, and failed renders are free. A product that issues 3,000 invoices a month needs 3,000 credits: the Growth plan has 12,000 for $40, about $0.0133 per invoice at that volume. Below 2,500 a month, Starter at $10 is enough. See pricing.

Get a free API key and send your invoice template as the first request.

Frequently asked questions

Should I send the invoice HTML or a URL to my invoice page?
Send the HTML in most cases. Your app renders the template with data it already has, and the renderer never needs access to a logged-in page. Use a URL only when the invoice page already exists and you can give the renderer a scoped token with network.auth.
How do I repeat the table header on every page of a long invoice?
Put the column titles in a real thead element. Chrome repeats a thead at the top of each printed page, as long as your CSS keeps its default display: table-header-group. In our Chromium test of a 48-line invoice, the column header printed at the top of both pages.
Why are my page numbers in Times New Roman?
Header and footer templates do not inherit the page's CSS. Without a font-family in the template, Chrome used Times for our footer. Put font-family and font-size inline in the template.
Is the invoice PDF public after the render?
The X-Cache-URL header is a public link to the file until the cache TTL ends. Do not share that link for invoices. Store the bytes in your own storage, and set cache.ttl to 3600 seconds, the minimum, so the CDN copy expires after one hour.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.