Skip to content

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.

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.

Add an HTTP Request node named Render invoice PDF:

FieldValue
MethodPOST
URLhttps://api.galleyrender.com/v1/render
AuthenticationGeneric Credential Type → Header Auth → Galley Render
Send Bodyon
Body Content TypeJSON
Specify BodyUsing JSON
JSONthe expression below
Response → Response FormatJSON

Switch the JSON field into expression mode (the fx toggle, or start the value with =) and paste:

Render invoice PDF → JSON body
={{ 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.

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.

Add a second HTTP Request node named Download PDF:

FieldValue
MethodGET
URL={{ $json.url }}
AuthenticationNone
Response → Response FormatFile
Response → Put Output in Fielddata

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:

  • AttachmentsBinary Propertydata
  • 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.

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:

422 Unprocessable Entity
{
"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 ErrorContinue (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:

Explain the validation error
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).

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 is queued or processing.
  • Webhook. Put an n8n Webhook node in a second workflow, pass its production URL as webhook_url in the render body, and Galley POSTs { "type": "render.succeeded", "created_at": …, "data": { …render… } } when it finishes. The signed URL is inside data.url.

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.

galley-invoice.json
{
"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" }
}

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.