Every billing system I have worked on has had the same weak spot. The data model is fine, the totals are right, the emails go out. Then someone in accounts opens the attached invoice, tries to copy the invoice number into their ledger, and finds it is a picture.
That happens because generating the file is the part nobody wants to own. You either bolt on a PDF library that fights your CSS, or you run headless Chrome in production and discover what a month-end batch does to memory. I have done both. This time I wanted the invoice PDF to be one HTTP call from a queued job, and I wanted to prove the result was a real PDF before a customer saw it.
I wrote up the full setup, including the receipt variant and the Laravel job, in Invoice PDF API: Generate Invoice and Receipt PDFs from HTML on html2img.com. This post is the shorter, first-person version: what I sent, what came back, and the checks I ran.
Prerequisites
- An HTML to Image API key (the free tier is enough to follow along)
-
curl, andpoppler-utilsforpdfinfo,pdffontsandpdftotext - Node 18+ if you want to run the receipt example
Step 1: send the invoice as JSON and ask for a PDF
The invoice image template takes the invoice as plain JSON. There is no HTML to write. The trick I did not know until recently is that every template accepts a format key, and setting it to pdf swaps the PNG for an A4 document.
curl -X POST https://app.html2img.com/api/v1/templates/invoice-image \
-H "X-API-Key: $HTML2IMG_KEY" \
-H "Content-Type: application/json" \
-d '{
"invoice_number": "INV-2026-0912",
"issue_date": "16 Sep 2026",
"due_date": "16 Oct 2026",
"business_name": "Fieldgate Studio Ltd",
"business_address": "4 Watergate Row\nChester CH1 2LE\nUnited Kingdom",
"client_name": "Northgate Coffee Ltd",
"client_address": "18 Bold Street\nLiverpool L1 4DS\nUnited Kingdom",
"items": [
{"description": "Online ordering system, phase 2", "quantity": "1", "unit_price": "$6,400.00", "amount": "$6,400.00"},
{"description": "Menu photography (half day)", "quantity": "2", "unit_price": "$550.00", "amount": "$1,100.00"},
{"description": "Hosting and monitoring, Q4", "quantity": "3", "unit_price": "$120.00", "amount": "$360.00"},
{"description": "Support retainer (hours)", "quantity": "8", "unit_price": "$95.00", "amount": "$760.00"}
],
"subtotal": "$8,620.00",
"tax_label": "VAT (20%)",
"tax_amount": "$1,724.00",
"total": "$10,344.00",
"notes": "Payment due within 30 days by bank transfer.",
"format": "pdf"
}'
Notice that every amount is a string. The template renders what you give it and does no arithmetic, which I think is the right call. My app already knows how to format money for the customer; the last thing I want is an API rounding differently to my ledger.
The response is a JSON envelope with a url that ends in .pdf:
{
"success": true,
"id": "968de823-85ed-4e79-904d-96168ab85240",
"template": "invoice-image",
"credits_remaining": 1234,
"url": "https://i.html2img.com/image-1789562462319-296970.pdf"
}
Here is the same request rendered as a PNG so you can see the layout. The actual PDF is here if you want to open it and highlight the text.
Step 2: check it is a document, not a screenshot
This is the step I would not skip. Plenty of "HTML to PDF" services hand you a PNG inside a PDF wrapper, and you only find out when a customer cannot search it. Three commands settle it:
curl -sL "https://i.html2img.com/image-1789562462319-296970.pdf" -o invoice.pdf
pdfinfo invoice.pdf | grep -E "Pages|Page size|Producer"
pdffonts invoice.pdf
pdftotext -layout invoice.pdf - | head -30
What I got back:
Producer: Skia/PDF m131
Pages: 1
Page size: 595.92 x 841.92 pts (A4)
pdffonts listed the template's Open Sans weights as embedded, subsetted TrueType. pdftotext printed the parties, every line item, the VAT row and the total, in reading order. So it is a Chromium-rendered vector PDF with a proper text layer, and the number in accounts can copy is actually copyable.
Step 3: the receipt, twice from one function
Receipts are the same idea with a smaller payload, and in practice I render them twice: a PNG for the confirmation email body and a PDF for the customer to file for expenses. One function, one argument:
const ENDPOINT = 'https://app.html2img.com/api/v1/templates/receipt-image';
async function renderReceipt(order, format = 'png') {
const res = await fetch(ENDPOINT, {
method: 'POST',
headers: {
'X-API-Key': process.env.HTML2IMG_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
business_name: 'Northgate Coffee',
order_number: order.number,
order_date: order.placedAt,
customer_name: order.customer.name,
items: order.lines.map((l) => ({
name: l.name,
qty: String(l.qty),
amount: l.amountFormatted,
})),
subtotal: order.subtotalFormatted,
shipping: order.shippingFormatted,
tax_amount: order.taxFormatted,
total: order.totalFormatted,
thank_you_message: 'Thanks for your order. Keep this receipt for your records.',
format,
}),
});
if (!res.ok) throw new Error(`Receipt render failed: ${res.status}`);
return (await res.json()).url;
}
const pngUrl = await renderReceipt(order);
const pdfUrl = await renderReceipt(order, 'pdf');
Two renders is two credits. That is still less than the cost of one browser process sitting idle on a server all month, which is what it replaced.
Step 4: when the template is not your brand, send your own HTML
The template covers the common case. When it does not, the /api/html endpoint takes your own markup with the same format key. From Laravel that is a Blade view and one Http call:
$html = view('invoices.pdf', ['invoice' => $invoice])->render();
$url = Http::withHeaders(['X-API-Key' => config('services.html2img.key')])
->timeout(60)
->post('https://app.html2img.com/api/html', ['html' => $html, 'format' => 'pdf'])
->throw()
->json('url');
Three things I learned about the stylesheet, because a PDF paginates and an image does not:
- The PDF uses your screen CSS.
@media printrules are not applied. Everything the document needs goes in the normal styles. - Pages are A4 portrait and content reflows to the page width. Design the invoice to flow, not to a fixed 1240px canvas.
-
break-inside: avoidontrand on the totals block works.<thead>does not repeat on page two, so if your invoices run long, repeat the header row yourself.
To check the pagination under load I pushed a 34-line invoice through. It came back as two clean A4 pages with no row split across the break and the totals kept together at the top of page two.
Step 5: put it in a queued job and keep the bytes
The one design rule I would push on anyone doing this: do not return the CDN URL to the customer. Render in a queued job when the invoice is finalised, download the PDF into your own storage keyed by invoice number, and attach from there. Free-tier CDN files expire after seven days, and on any plan the invoice needs to still exist when an auditor asks in five years.
class RenderInvoicePdf implements ShouldQueue
{
public int $tries = 3;
public function __construct(public Invoice $invoice) {}
public function handle(): void
{
$response = Http::withHeaders(['X-API-Key' => config('services.html2img.key')])
->timeout(60)
->post('https://app.html2img.com/api/v1/templates/invoice-image', [
...$this->invoice->toTemplatePayload(),
'format' => 'pdf',
])
->throw();
$path = "invoices/{$this->invoice->year}/{$this->invoice->number}.pdf";
Storage::disk('s3')->put($path, Http::get($response->json('url'))->body());
$this->invoice->update(['pdf_path' => $path]);
Mail::to($this->invoice->client_email)->send(new InvoiceIssued($this->invoice));
}
}
If you are generating thousands at month end, pass a webhook_url in the request instead and let the callback drive the storage step rather than holding a worker on each render.
What I would tell past me
The invoice PDF was never the hard part; owning a renderer was. One extra key on a request I was already making, three commands to prove the output is real, and a job that keeps the bytes. The longer version with the receipt reference, the page-break stylesheet and the full Laravel setup is in the html2img.com article.
How are you generating invoices today? If you are still on wkhtmltopdf or dompdf, I would be interested to hear what is keeping you there. Drop it in the comments.


Top comments (0)