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_urlso it resolves (content input). - The bearer token goes only to the invoice's origin.
network.authis 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_pagescales 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-sizebut nofont-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.
Related
- Reports with charts, landscape pages and one-page summaries: PDF reports.
- Doing it yourself in Rails: wicked_pdf, Grover or Prawn. In Node with Puppeteer, Playwright or PDFKit: HTML to PDF in Node.js. In Laravel: dompdf, Browsershot or laravel-pdf.
- Every PDF option: PDF parameters.
Get a free API key and send your invoice template as the first request.