Add a PDF step to a Zap
There is no published Galley app in the Zapier directory yet, so you will not find “Galley Render” in the app search. You do not need it. Galley is one JSON POST, and Webhooks by Zapier → Custom Request sends exactly that.
This guide covers the simple case (flat data, no nesting), then the case that actually stops people: building a nested line_items array, which Zapier’s data editor cannot express.
The action step, field by field
Section titled “The action step, field by field”Add an action, choose Webhooks by Zapier, and pick the event Custom Request. Not “POST” — the POST event tries to be helpful about serialisation and gets in the way here. Custom Request sends your body verbatim.
| Field | Value |
|---|---|
| Method | POST |
| URL | https://api.galleyrender.com/v1/render |
| Data | the JSON body (below) |
| Unflatten | no |
| Headers | Content-Type → application/jsonAuthorization → Bearer glr_sk_… |
Set Unflatten to no. Left on, Zapier expands dotted key names into nested objects behind your back, which silently rewrites a payload you have already written correctly.
Galley also accepts the key as X-API-Key: glr_sk_… if you find the Bearer prefix awkward to keep intact when editing the header.
For a flat template — an OG card, a receipt header, a shipping label — the Data field is plain JSON with Zapier field tokens dropped in. Insert the tokens from the field picker; they render as {{123456789__field_name}} in the raw view:
{ "template": "og-card@1", "format": "png", "data": { "eyebrow": "Galley Render", "title": "{{123456789__post_title}}", "subtitle": "{{123456789__post_summary}}", "accent": "#2dd4bf", "background": "#0b0f14" }}Reading the response in later steps
Section titled “Reading the response in later steps”Webhooks by Zapier parses the JSON response and exposes every field to the steps that follow. From a step named Render PDF you get, among others:
| Token | Example value |
|---|---|
url | https://<account>.r2.cloudflarestorage.com/galley-renders/renders/acc_9m3x/2026/09/4f1c…e7.pdf?X-Amz-Expires=3600&X-Amz-Signature=… |
id | rnd_7hq2m4x8k1bv |
status | succeeded |
template | invoice@1 |
page_count | 1 |
billable_units | 1 |
cached | false |
The full response body is:
{ "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/…?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}Map url into the next action. Gmail, Outlook and most Zapier email actions accept a URL in their Attachment field and fetch the file themselves. Google Drive’s Upload File takes it in File. Slack’s Send Channel Message can take it as a link, though an expiring link in a Slack message ages badly — see the note below.
The nested array problem
Section titled “The nested array problem”Now the real one. An invoice needs this:
"line_items": [ { "description": "Starter plan, September", "quantity": 1, "unit_price": 19 }, { "description": "Overage, 3,200 renders", "quantity": 3.2, "unit_price": 4 }]Zapier’s trigger data is flat. A line-items table from Stripe, Airtable or Google Sheets arrives either as parallel comma-separated strings —
descriptions: "Starter plan, September,Overage, 3,200 renders"quantities: "1,3.2"unit_prices: "19,4"— or as a set of line-item child fields Zapier has already split into arrays. Neither can be assembled into an array of objects in the data editor. Every workaround people try (dotted keys plus Unflatten, “Looping by Zapier”, writing [{...}] by hand into the Data field) breaks on the first comma inside a description.
The fix is Code by Zapier → Run JavaScript, which builds the payload properly and makes the HTTP call, so you can delete the Webhooks step entirely.
Set up the Code step
Section titled “Set up the Code step”Add Code by Zapier → Run JavaScript. In Input Data, create these keys and map each to a trigger field:
| Key | Mapped from |
|---|---|
api_key | (type your key, or a Storage/secret step) |
invoice_number | trigger invoice number |
issued_on | trigger date, YYYY-MM-DD |
due_on | trigger due date, YYYY-MM-DD |
customer_name | trigger customer name |
customer_email | trigger customer email |
tax_rate | trigger tax rate, e.g. 0.07 |
descriptions | the line-item descriptions field |
quantities | the line-item quantities field |
unit_prices | the line-item unit prices field |
Input Data values always arrive as strings, even when the source was a number or an array. When Zapier maps a multi-value line-item field into a single input, it joins it with commas. Both facts are handled below.
The code
Section titled “The code”// Zapier's JavaScript runtime is Node with a global `fetch`.// Every value in `inputData` is a string.
/** Zapier joins multi-value fields with commas. Split, trim, drop blanks. */function list(value) { if (value === undefined || value === null || value === "") return []; return String(value) .split(",") .map((s) => s.trim()) .filter((s) => s.length > 0);}
const descriptions = list(inputData.descriptions);const quantities = list(inputData.quantities).map(Number);const unitPrices = list(inputData.unit_prices).map(Number);
if (descriptions.length === 0) { throw new Error("No line items: check the `descriptions` input mapping.");}if (descriptions.length !== quantities.length || descriptions.length !== unitPrices.length) { throw new Error( `Line item columns are ragged: ${descriptions.length} descriptions, ` + `${quantities.length} quantities, ${unitPrices.length} prices. ` + "A comma inside a description will do this — see the guide.", );}
const lineItems = descriptions.map((description, i) => ({ description, quantity: quantities[i], // numbers, not strings — the schema is strict unit_price: unitPrices[i],}));
const body = { template: "invoice@1", // pin the version in anything you ship format: "pdf", options: { page_size: "Letter", margin: "0.5in", print_background: true }, data: { invoice_number: inputData.invoice_number, issued_on: inputData.issued_on, due_on: inputData.due_on, currency: "USD", seller: { name: "Galley Render", email: "billing@galleyrender.com", address: "2727 Jean Lafitte Dr, Fernandina Beach, FL 32034", }, buyer: { name: inputData.customer_name, email: inputData.customer_email, address: inputData.customer_address, }, line_items: lineItems, tax_rate: Number(inputData.tax_rate || 0), },};
const res = await fetch("https://api.galleyrender.com/v1/render", { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${inputData.api_key}`, }, body: JSON.stringify(body), // escapes quotes, newlines and unicode correctly});
const payload = await res.json();
if (!res.ok) { const err = payload.error || {}; const fields = (err.errors || []) .map((f) => `${f.path}: expected ${f.expected}, received ${f.received}`) .join("; "); // Throwing marks the Zap step as errored and puts this text in the Zap history. throw new Error(`${err.type || res.status}: ${err.message || "Render failed"}${fields ? ` — ${fields}` : ""}`);}
output = { render_id: payload.id, url: payload.url, status: payload.status, page_count: payload.page_count, billable_units: payload.billable_units, cached: payload.cached, line_item_count: lineItems.length,};Later steps see url, render_id, page_count and the rest as ordinary fields from the Code step.
If a description can contain a comma
Section titled “If a description can contain a comma”Splitting on commas is wrong the moment a description reads "Overage, 3,200 renders". If you control the source, join on a character that will not appear — a pipe — and split on that instead:
function list(value, sep = "|") { return String(value || "").split(sep).map((s) => s.trim()).filter(Boolean);}If the source is a Looping by Zapier step or an Airtable/Sheets Find Many action, Zapier sometimes hands the Code step a real JSON array as a string. Handle both:
function list(value) { if (Array.isArray(value)) return value; const s = String(value ?? "").trim(); if (s.startsWith("[")) { try { return JSON.parse(s); } catch { /* fall through to splitting */ } } return s ? s.split(",").map((t) => t.trim()).filter(Boolean) : [];}The ragged-length check in the main script exists precisely to turn this class of bug into a legible error in your Zap history instead of an invoice with the wrong prices on it.
Handling the 422
Section titled “Handling the 422”If the payload does not match the template’s JSON Schema, Galley returns 422 and renders nothing — you are not billed for a rejected request:
{ "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 } ] }}With the Webhooks step, Zapier halts the Zap and shows the status code; open the step’s Data Out in Zap history to read error.errors. With the Code step, the throw above puts the field path and the expected type straight into the error message, which is also what lands in your Zap error email.
Other statuses you may meet: 401 authentication_error (key wrong or revoked), 402 quota_exceeded (free tier or trial spent — see pricing), 404 not_found (template name wrong; details.available_templates lists the real ones), 429 rate_limited (Zapier retries automatically).
Caching
Section titled “Caching”Billing is one unit per PDF page and one per PNG or JPG. Cache hits are free: an identical template version, data and options returns the stored file with cached: true. Zapier replays and manual re-runs therefore cost nothing extra, provided the payload is identical — a Date.now() or a “generated at” timestamp in data would defeat it.
What next
Section titled “What next”- Add a PDF step to an n8n workflow — the same job with full JavaScript expressions.
- Caching and signed URLs — what the cache key covers and how long URLs last.
- Pricing — the free tier, per-render pricing and the plans.