n8n has no built-in "HTML to PDF" node. The HTTP Request node can call a PDF API from n8n Cloud or self-hosted alike: send your HTML, get a PDF back as a binary file, and pass it to Google Drive, Gmail or S3. Below is a workflow you can paste straight into the canvas, plus the one body setting that breaks most first attempts.
What was tested (October 2026): the workflow on this page was imported into n8n 2.44.6 (self-hosted, Node 24) and run with n8n execute against the live API. The invoice PDF came back as one A4 page (19 KB) in 0.13 s, and its text was checked with pdftotext. The broken body below was run too; the error is copied from that run.
A key gives 25 free renders a month, no signup. Click Get a free key, or run a throwaway HTTP Request node once with Method POST and URL https://shotmint.creasework.com/v1/keys/free and copy key from the output:
{
"key": "sm_...",
"credits": 25,
"topup_url": "https://shotmint.creasework.com/pricing#topup=sm_..."
}
Then delete that node: one free key per IP per day, so a second run would get a 429.
Copy this JSON, click an empty spot on the n8n canvas and press Ctrl+V (Cmd+V on a Mac). You get three nodes: Manual Trigger → Build invoice HTML (Code) → HTML to PDF (HTTP Request). Replace YOUR_KEY, click Execute workflow, and the last node outputs the PDF.
{
"nodes": [
{
"name": "Manual Trigger",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [
0,
0
],
"parameters": {}
},
{
"name": "Build invoice HTML",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
220,
0
],
"parameters": {
"jsCode": "// Sample invoice data. In a real workflow this comes from a trigger, Sheets row, Stripe payment...\nconst inv = {\n number: 'INV-1042',\n customer: 'Acme \"Rocket\" Corp',\n address: '12 Main St\\nSpringfield',\n items: [\n { name: 'Widget, large', qty: 3, price: 19.5 },\n { name: '5\" cable', qty: 2, price: 4.25 },\n ],\n};\nconst esc = s => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');\nconst rows = inv.items.map(i =>\n `<tr><td>${esc(i.name)}</td><td>${i.qty}</td><td>${(i.qty * i.price).toFixed(2)}</td></tr>`).join('');\nconst total = inv.items.reduce((t, i) => t + i.qty * i.price, 0).toFixed(2);\nconst html = `<style>\n body { font-family: sans-serif; margin: 0 }\n table { width: 100%; border-collapse: collapse }\n td, th { border-bottom: 1px solid #ddd; padding: 8px; text-align: left }\n</style>\n<h1>Invoice ${esc(inv.number)}</h1>\n<p>Billed to: ${esc(inv.customer)}<br>${esc(inv.address).replace(/\\n/g, '<br>')}</p>\n<table><tr><th>Item</th><th>Qty</th><th>Amount</th></tr>${rows}</table>\n<p><b>Total: ${total}</b></p>`;\nreturn [{ json: { number: inv.number, customer: inv.customer, html } }];\n"
}
},
{
"name": "HTML to PDF",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
440,
0
],
"parameters": {
"method": "POST",
"url": "https://shotmint.creasework.com/v1/pdf",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "X-API-Key",
"value": "YOUR_KEY"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ html: $json.html, format: 'A4', margin: '15mm', print_background: true }) }}",
"options": {
"timeout": 60000,
"response": {
"response": {
"responseFormat": "file",
"outputPropertyName": "data"
}
}
}
}
}
],
"connections": {
"Manual Trigger": {
"main": [
[
{
"node": "Build invoice HTML",
"type": "main",
"index": 0
}
]
]
},
"Build invoice HTML": {
"main": [
[
{
"node": "HTML to PDF",
"type": "main",
"index": 0
}
]
]
}
}
}
For anything you share or export, move the key into a credential: in the HTTP Request node set Authentication to Generic Credential Type → Header Auth with Name X-API-Key and your key as the Value, then turn off Send Headers. Credentials are not included when you copy or export a workflow.
| Field | Value |
|---|---|
| Method | POST |
| URL | https://shotmint.creasework.com/v1/pdf |
| Send Headers | X-API-Key = your keyor Header Auth credential, see above |
| Send Body | on, Body Content Type JSON |
| Specify Body | Using JSON, with an expression (next section) or Using Fields Below |
| Options → Response | Response Format File Put Output in Field: data |
| Options → Timeout | 60000 ms |
The output item carries a binary property data: application/pdf, file name page.pdf.
The obvious way to fill the JSON body is to type JSON and drop the HTML into it:
{
"html": "{{ $json.html }}",
"format": "A4",
"margin": "15mm"
}
That fails before any request is sent. n8n pastes $json.html in as raw text, so the first line break or double quote in your HTML ends the JSON string early. HTML almost always contains both. Our run stopped at the HTTP Request node with:
The value in the "JSON Body" field is not valid JSON
Bad control character in string literal in JSON at position 20 (line 2 column 19)
The fix is to let JavaScript build the whole body. Set the JSON field to Expression and use:
{{ JSON.stringify({ html: $json.html, format: 'A4', margin: '15mm', print_background: true }) }}
JSON.stringify escapes quotes, line breaks and backslashes, so a customer called Acme "Rocket" Corp or an address over two lines goes through untouched; both are in the test invoice above. Using Fields Below also works (add html, format, margin as name/value rows, with {{ $json.html }} as the html value): n8n encodes those itself, and our run produced the same PDF byte size.
This is the code inside Build invoice HTML. Swap the sample object for $input.first().json (or loop over $input.all() for one PDF per item) and adjust the template:
// Sample invoice data. In a real workflow this comes from a trigger, Sheets row, Stripe payment...
const inv = {
number: 'INV-1042',
customer: 'Acme "Rocket" Corp',
address: '12 Main St\nSpringfield',
items: [
{ name: 'Widget, large', qty: 3, price: 19.5 },
{ name: '5" cable', qty: 2, price: 4.25 },
],
};
const esc = s => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
const rows = inv.items.map(i =>
`<tr><td>${esc(i.name)}</td><td>${i.qty}</td><td>${(i.qty * i.price).toFixed(2)}</td></tr>`).join('');
const total = inv.items.reduce((t, i) => t + i.qty * i.price, 0).toFixed(2);
const html = `<style>
body { font-family: sans-serif; margin: 0 }
table { width: 100%; border-collapse: collapse }
td, th { border-bottom: 1px solid #ddd; padding: 8px; text-align: left }
</style>
<h1>Invoice ${esc(inv.number)}</h1>
<p>Billed to: ${esc(inv.customer)}<br>${esc(inv.address).replace(/\n/g, '<br>')}</p>
<table><tr><th>Item</th><th>Qty</th><th>Amount</th></tr>${rows}</table>
<p><b>Total: ${total}</b></p>`;
return [{ json: { number: inv.number, customer: inv.customer, html } }];
The esc() helper matters for the HTML itself: a < or & in a product name would otherwise be read as markup. Keep CSS in a <style> tag; Chromium prints the page like a browser would. Images need absolute https:// URLs, and Google Fonts load normally. Other body options: "landscape": true, "format": "Letter", "page_numbers": true (centred "1 / 3" footer), or "url" instead of "html" to print an existing page. All of them are in the API reference.
data, File Name {{ $('Build invoice HTML').item.json.number }}.pdf.data.data.data. This is what our test run used.Without a file name of your own, the file is saved as page.pdf; set one per invoice so files don't overwrite each other.
| Status | Meaning and fix |
|---|---|
400 | Bad body or parameter the JSON error text says what is wrong |
401 | Missing or wrong key header name must be X-API-Key |
402 | Out of credits the body has a top-up link; €5 buys 2,000 PDFs that never expire |
503 | Queue full during a burst safe to retry; use Options → Batching |
The API renders 3 pages at a time. When a workflow sends hundreds of items, set the HTTP Request node's Batching option (for example 3 items per batch) and turn on Retry On Fail in the node settings. Failed renders are not charged, and one invoice is one credit, so 100 invoices a month runs for 20 months on a €5 pack. Check the balance with a GET to https://shotmint.creasework.com/v1/account and the same header.
Screenshots work the same way (GET /v1/screenshot?url=..., Response Format File), see screenshots in n8n, Make and Zapier. Using Make instead? See HTML to PDF in Make. For page layout, see page breaks and page numbers.
No signup, no card. Get a key in one click, then buy credits only if you need more (from €5 for 2,000, never expire). From a script, an AI agent, Make or Zapier, POST /v1/keys/free (no body) returns a key as JSON (openapi.json, no-code recipes).
All guides: guides · integrations · comparisons