Add a PDF step to an n8n workflow
n8n has no official Galley node yet — there is no n8n-nodes-galley community package, and nothing to install. You do not need one. Galley is a single JSON POST, which the built-in HTTP Request node does well, and going through HTTP Request means you get the full request body rather than whatever a node author chose to expose.
This guide wires up: trigger → build the payload → render the PDF → download it → email it as an attachment.
Tested against n8n 1.x, where the HTTP Request node is at typeVersion 4.2. If your instance shows a different version the fields are named the same; only the import JSON below would need its typeVersion adjusted.
Store the key as a credential
Section titled “Store the key as a credential”Do not paste the key into a node. In n8n, go to Credentials → New → Header Auth and create one:
- Name:
Galley Render - Header Name:
Authorization - Header Value:
Bearer glr_sk_…
Then in the HTTP Request node set Authentication to Generic Credential Type, Generic Auth Type to Header Auth, and pick that credential. Galley also accepts X-API-Key: glr_sk_… if you would rather not type the Bearer prefix into the value.
The render node, field by field
Section titled “The render node, field by field”Add an HTTP Request node named Render invoice PDF:
| Field | Value |
|---|---|
| Method | POST |
| URL | https://api.galleyrender.com/v1/render |
| Authentication | Generic Credential Type → Header Auth → Galley Render |
| Send Body | on |
| Body Content Type | JSON |
| Specify Body | Using JSON |
| JSON | the expression below |
| Response → Response Format | JSON |
Switch the JSON field into expression mode (the fx toggle, or start the value with =) and paste:
={{ JSON.stringify({ template: "invoice@1", format: "pdf", options: { page_size: "Letter", margin: "0.5in", print_background: true }, data: { invoice_number: $json.invoice_number, issued_on: $json.issued_on, due_on: $json.due_on, currency: "USD", seller: { name: "Galley Render", email: "billing@galleyrender.com", address: "2727 Jean Lafitte Dr, Fernandina Beach, FL 32034" }, buyer: { name: $json.customer_name, email: $json.customer_email, address: $json.customer_address }, line_items: ($json.lines || []).map(l => ({ description: String(l.description), quantity: Number(l.quantity), unit_price: Number(l.unit_price) })), tax_rate: Number($json.tax_rate || 0) }}) }}Three things are doing real work here.
JSON.stringify around the whole object. The Using JSON mode wants a JSON string. Building the object in JavaScript and stringifying it once is far more reliable than writing raw JSON with {{ }} holes punched in it, where a quote or an apostrophe in a customer name breaks the document.
Number(...) on every numeric field. The invoice schema requires quantity and unit_price to be numbers. Values arriving from a spreadsheet, a webhook query string or a form are strings, and Galley will reject "2" where it wants 2. Coerce at the boundary.
.map() for line_items. n8n’s expression language is full JavaScript, so a nested array is just an array. This is the part that is painful in other tools and easy here.
To pull from a specific earlier node rather than the immediately previous one, use $('Node name').item.json.field — for example $('Fetch order').item.json.customer_name.
What the node outputs
Section titled “What the node outputs”On success the node’s output item is the render object:
{ "object": "render", "id": "rnd_7hq2m4x8k1bv", "status": "succeeded", "template": "invoice@1", "format": "pdf", "engine": "chromium", "cached": false, "url": "https://<account>.r2.cloudflarestorage.com/galley-renders/renders/acc_9m3x/2026/09/4f1c…e7.pdf?X-Amz-Expires=3600&X-Amz-Signature=…", "expires_at": "2026-10-16T14:20:11.004Z", "page_count": 1, "billable_units": 1, "byte_size": 48213, "content_type": "application/pdf", "created_at": "2026-09-16T14:20:09.841Z", "completed_at": "2026-09-16T14:20:11.004Z", "error": null}{{ $json.url }} is the signed URL. It needs no credentials and it is good for an hour.
Downloading the file
Section titled “Downloading the file”Add a second HTTP Request node named Download PDF:
| Field | Value |
|---|---|
| Method | GET |
| URL | ={{ $json.url }} |
| Authentication | None |
| Response → Response Format | File |
| Response → Put Output in Field | data |
Leave authentication off. The signature is already in the URL, and adding an Authorization header to a presigned request is a common way to get a 403.
That node emits a binary item. A following Send Email (or Gmail / Outlook) node attaches it:
- Attachments → Binary Property →
data - Subject →
={{ 'Invoice ' + $('Render invoice PDF').item.json.template }}
If your email node wants a filename, add a Code node between download and send, or set the download node’s Options → Response → Output File Name to something like ={{ $('Render invoice PDF').item.json.id }}.pdf.
Handling the 422
Section titled “Handling the 422”The most common failure is a payload that does not match the template schema — a missing issued_on, an empty line_items, a quantity that is still a string. Galley returns 422 and renders nothing:
{ "error": { "type": "validation_error", "message": "The data payload does not match the template schema.", "docs_url": "https://galleyrender.com/docs/errors/validation_error", "errors": [ { "path": "data.line_items[0].quantity", "message": "data.line_items[0].quantity must be number", "expected": "number", "received": "string", "example": 2 } ] }}By default the HTTP Request node treats any non-2xx as a node failure and stops the execution, and n8n shows you the status code without the body — which is exactly the part you need.
Fix it in the node’s Settings tab:
- On Error → Continue (using error output). In older builds this is the Continue On Fail toggle. Either way, turn on Options → Response → Never Error or Include Response Headers and Status so the body reaches the next node instead of being swallowed.
Then add an IF node on the error branch:
- Condition:
{{ $json.error?.type }}— is not empty
and, on the true branch, a Code node to turn the field errors into something a human can read:
const err = $input.first().json.error ?? {};const lines = (err.errors ?? []).map( (f) => `${f.path}: expected ${f.expected}, received ${f.received}` + (f.example !== undefined ? ` (e.g. ${JSON.stringify(f.example)})` : ""));return [{ json: { type: err.type, message: err.message, docs_url: err.docs_url, detail: lines.join("\n") || err.message,} }];Route that to a Slack message or a “needs attention” row. The point is that errors[].path tells you precisely which record in your source data is malformed, which is worth far more than a retry.
Other statuses worth branching on: 401 authentication_error (bad or revoked key), 402 quota_exceeded (free tier or trial spent), 404 not_found (template name wrong — details.available_templates lists what does exist), and 429 rate_limited (back off; n8n’s Retry On Fail with a few seconds of wait handles this).
The queued path
Section titled “The queued path”For a 200-page report, add async: true to the body and the call returns 202 immediately with status: "queued". Then either:
- Poll. A Wait node (5 seconds), an HTTP Request node
GET https://api.galleyrender.com/v1/renders/{{ $json.id }}with the same Header Auth credential, and an IF on{{ $json.status }}that loops back to Wait while it isqueuedorprocessing. - Webhook. Put an n8n Webhook node in a second workflow, pass its production URL as
webhook_urlin the render body, and Galley POSTs{ "type": "render.succeeded", "created_at": …, "data": { …render… } }when it finishes. The signed URL is insidedata.url.
Importable workflow
Section titled “Importable workflow”Copy this, then in n8n use Workflows → Import from Clipboard. It contains the manual trigger, a Set node standing in for your real data source, the render node and the download node. Attach the Header Auth credential to Render invoice PDF after importing — credentials are never included in an export.
{ "name": "Galley — invoice PDF", "nodes": [ { "parameters": {}, "id": "a1000000-0000-4000-8000-000000000001", "name": "When clicking Test workflow", "type": "n8n-nodes-base.manualTrigger", "typeVersion": 1, "position": [0, 0] }, { "parameters": { "mode": "raw", "jsonOutput": "{\n \"invoice_number\": \"INV-1042\",\n \"issued_on\": \"2026-09-16\",\n \"due_on\": \"2026-10-16\",\n \"customer_name\": \"Acme Robotics\",\n \"customer_email\": \"ap@acme.test\",\n \"customer_address\": \"100 Market St, Austin, TX 78701\",\n \"tax_rate\": 0.07,\n \"lines\": [\n { \"description\": \"Starter plan, September\", \"quantity\": 1, \"unit_price\": 19 },\n { \"description\": \"Overage, 3,200 renders\", \"quantity\": 3.2, \"unit_price\": 4 }\n ]\n}", "options": {} }, "id": "a1000000-0000-4000-8000-000000000002", "name": "Order data", "type": "n8n-nodes-base.set", "typeVersion": 3.4, "position": [220, 0] }, { "parameters": { "method": "POST", "url": "https://api.galleyrender.com/v1/render", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ template: \"invoice@1\", format: \"pdf\", options: { page_size: \"Letter\", margin: \"0.5in\", print_background: true }, data: { invoice_number: $json.invoice_number, issued_on: $json.issued_on, due_on: $json.due_on, currency: \"USD\", seller: { name: \"Galley Render\", email: \"billing@galleyrender.com\" }, buyer: { name: $json.customer_name, email: $json.customer_email, address: $json.customer_address }, line_items: ($json.lines || []).map(l => ({ description: String(l.description), quantity: Number(l.quantity), unit_price: Number(l.unit_price) })), tax_rate: Number($json.tax_rate || 0) } }) }}", "options": { "response": { "response": { "neverError": true, "responseFormat": "json" } } } }, "id": "a1000000-0000-4000-8000-000000000003", "name": "Render invoice PDF", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [440, 0], "notes": "Set Authentication to Generic Credential Type -> Header Auth after import." }, { "parameters": { "url": "={{ $json.url }}", "options": { "response": { "response": { "responseFormat": "file", "outputPropertyName": "data" } } } }, "id": "a1000000-0000-4000-8000-000000000004", "name": "Download PDF", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [660, 0] } ], "connections": { "When clicking Test workflow": { "main": [[{ "node": "Order data", "type": "main", "index": 0 }]] }, "Order data": { "main": [[{ "node": "Render invoice PDF", "type": "main", "index": 0 }]] }, "Render invoice PDF": { "main": [[{ "node": "Download PDF", "type": "main", "index": 0 }]] } }, "settings": { "executionOrder": "v1" }}Costs and caching
Section titled “Costs and caching”One unit per PDF page, one per PNG or JPG, and cache hits are free. A workflow that re-runs over the same row — a retried execution, a re-processed webhook — returns the stored file with cached: true and costs nothing, so you do not need to build idempotency into the workflow yourself. Just make sure the payload is byte-identical; a timestamp in data defeats it.
What next
Section titled “What next”- Add a PDF step to a Zap — the same job in Zapier.
- Webhooks — the queued path and the callback payload.
- Template gallery — the twenty-two starter templates and their schemas.