Skip to content

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.

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.

FieldValue
MethodPOST
URLhttps://api.galleyrender.com/v1/render
Datathe JSON body (below)
Unflattenno
HeadersContent-Typeapplication/json
AuthorizationBearer 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:

Data
{
"template": "og-card@1",
"format": "png",
"data": {
"eyebrow": "Galley Render",
"title": "{{123456789__post_title}}",
"subtitle": "{{123456789__post_summary}}",
"accent": "#2dd4bf",
"background": "#0b0f14"
}
}

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:

TokenExample value
urlhttps://<account>.r2.cloudflarestorage.com/galley-renders/renders/acc_9m3x/2026/09/4f1c…e7.pdf?X-Amz-Expires=3600&X-Amz-Signature=…
idrnd_7hq2m4x8k1bv
statussucceeded
templateinvoice@1
page_count1
billable_units1
cachedfalse

The full response body is:

200 OK
{
"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.

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.

Add Code by Zapier → Run JavaScript. In Input Data, create these keys and map each to a trigger field:

KeyMapped from
api_key(type your key, or a Storage/secret step)
invoice_numbertrigger invoice number
issued_ontrigger date, YYYY-MM-DD
due_ontrigger due date, YYYY-MM-DD
customer_nametrigger customer name
customer_emailtrigger customer email
tax_ratetrigger tax rate, e.g. 0.07
descriptionsthe line-item descriptions field
quantitiesthe line-item quantities field
unit_pricesthe 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.

Code by Zapier — Run JavaScript
// 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.

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.

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:

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
}
]
}
}

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).

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.