Generate a PDF invoice from JSON in Node
You have invoice data in a database or an order object, and you need a PDF a customer can download. This guide is one file, no dependencies, that turns that object into a PDF on disk.
Requires Node 22 or newer. fetch, AbortSignal.timeout and node:fs/promises are all built in, so there is nothing to install. There is no published Galley SDK yet — galley-render on npm is reserved but unpublished, so do not try to install it. Everything below is plain HTTP.
Set your key first:
export GALLEY_API_KEY=glr_sk_…If you do not have one, a keyless trial token from the MCP server works here too: trial tokens are ordinary API keys and carry 50 renders.
The whole script
Section titled “The whole script”// Node 22+. No dependencies.// Usage: GALLEY_API_KEY=glr_sk_… node render-invoice.mjsimport { writeFile } from "node:fs/promises";
const API_BASE = "https://api.galleyrender.com";const API_KEY = process.env.GALLEY_API_KEY;
if (!API_KEY) { console.error("Set GALLEY_API_KEY first."); process.exit(1);}
/** The API's error envelope, kept intact so the caller can read `type` and `errors`. */class GalleyError extends Error { constructor(status, body) { const err = body?.error ?? {}; super(err.message ?? `Galley returned HTTP ${status}`); this.name = "GalleyError"; this.status = status; this.type = err.type ?? "internal_error"; this.docsUrl = err.docs_url; this.fieldErrors = err.errors ?? []; this.details = err.details; }}
async function galley(path, { method = "GET", body } = {}) { const res = await fetch(`${API_BASE}${path}`, { method, headers: { authorization: `Bearer ${API_KEY}`, ...(body ? { "content-type": "application/json" } : {}), }, body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(60_000), });
const text = await res.text(); let payload; try { payload = JSON.parse(text); } catch { throw new GalleyError(res.status, { error: { message: text.slice(0, 500) } }); } if (!res.ok) throw new GalleyError(res.status, payload); return payload;}
/** Your own order shape -> the `invoice` template's data payload. */function toInvoicePayload(order) { return { invoice_number: order.number, issued_on: order.issuedOn, // "YYYY-MM-DD" due_on: order.dueOn, currency: "USD", notes: "Payment due within 30 days. ACH details on request.", seller: { name: "Galley Render", email: "billing@galleyrender.com", address: "2727 Jean Lafitte Dr, Fernandina Beach, FL 32034", }, buyer: { name: order.customer.name, email: order.customer.email, address: order.customer.address, }, line_items: order.lines.map((line) => ({ description: line.description, quantity: Number(line.quantity), // numbers, not strings — the schema is strict unit_price: Number(line.unitPrice), })), tax_rate: order.taxRate, };}
async function renderInvoice(order) { return galley("/v1/render", { method: "POST", body: { template: "invoice@1", // pin the version in anything you ship format: "pdf", data: toInvoicePayload(order), options: { page_size: "Letter", margin: "0.5in", print_background: true }, }, });}
/** The signed URL needs no auth header, and it is good for an hour. */async function download(url, path) { const res = await fetch(url, { signal: AbortSignal.timeout(60_000) }); if (!res.ok) throw new Error(`Download failed: HTTP ${res.status}`); await writeFile(path, Buffer.from(await res.arrayBuffer())); return path;}
const order = { number: "INV-1042", issuedOn: "2026-09-16", dueOn: "2026-10-16", taxRate: 0.07, customer: { name: "Acme Robotics", email: "ap@acme.test", address: "100 Market St, Austin, TX 78701", }, lines: [ { description: "Starter plan, September", quantity: 1, unitPrice: 19 }, { description: "Overage, 3,200 renders", quantity: 3.2, unitPrice: 4 }, ],};
try { const render = await renderInvoice(order); console.log(JSON.stringify(render, null, 2));
const path = await download(render.url, `${order.number}.pdf`); console.log( `Saved ${path} — ${render.page_count} page(s), ` + `${render.billable_units} billable unit(s), cached: ${render.cached}`, );} catch (err) { if (err instanceof GalleyError && err.type === "validation_error") { console.error("The payload does not match the invoice schema:"); for (const f of err.fieldErrors) { console.error(` ${f.path}: expected ${f.expected}, received ${f.received}`); if (f.example !== undefined) console.error(` e.g. ${JSON.stringify(f.example)}`); } process.exit(2); } if (err instanceof GalleyError) { console.error(`${err.type} (HTTP ${err.status}): ${err.message}`); if (err.docsUrl) console.error(err.docsUrl); process.exit(1); } throw err;}What comes back
Section titled “What comes back”A one- or two-page invoice is a small job, so it renders inside the request and returns 200 with the URL already filled in:
{ "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}Two fields are easy to confuse. The expires parameter inside url is the signed URL expiry, one hour out. The top-level expires_at is when the stored object is deleted, thirty days out. When a URL goes stale, call GET /v1/renders/rnd_7hq2m4x8k1bv for a fresh one instead of re-rendering:
const fresh = await galley(`/v1/renders/${render.id}`);await download(fresh.url, "INV-1042.pdf");Handling the 422
Section titled “Handling the 422”The invoice schema requires invoice_number, issued_on, seller, buyer and line_items, and it requires quantity and unit_price to be numbers. A string that looks like a number is still a string. Change one line to see it:
{ description: "Starter plan, September", quantity: "1", unitPrice: 19 },The request comes back 422 before anything is rendered or billed:
{ "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 } ] }}The script prints:
The payload does not match the invoice schema: data.line_items[0].quantity: expected number, received string e.g. 2Every field error carries a path, an expected, a received and — where the schema gives one — an example. That is enough to fix the payload programmatically; you never have to guess at the shape.
The cache hit
Section titled “The cache hit”Run the script a second time without changing anything:
Saved INV-1042.pdf — 1 page(s), 1 billable unit(s), cached: trueThe cache key is a hash of the template version, the format, the canonical JSON of data and the canonical JSON of options. Identical input returns the stored object with cached: true, instantly, and cache hits are never billed. Key ordering does not matter — the JSON is canonicalised first — but any real change to the data, the options or the template version is a different render.
This is why you should re-send the identical request rather than caching the PDF yourself. It is free, and it keeps one source of truth.
The queued path and a webhook
Section titled “The queued path and a webhook”Anything with a webhook_url, with async: true, or with more than 32 KB of data takes the queued path instead. The call returns 202 immediately:
const queued = await galley("/v1/render", { method: "POST", body: { template: "invoice@1", format: "pdf", data: toInvoicePayload(order), webhook_url: "https://example.com/hooks/galley", },});// { "object": "render", "id": "rnd_…", "status": "queued", "url": null, … }When it finishes, Galley POSTs this to your URL:
{ "type": "render.succeeded", "created_at": "2026-09-16T14:21:02.118Z", "data": { "object": "render", "id": "rnd_7hq2m4x8k1bv", "status": "succeeded", "url": "https://<account>.r2.cloudflarestorage.com/galley-renders/renders/…", "page_count": 1, "billable_units": 1 }}A failed render sends the same envelope with "type": "render.failed" and the error under data.error. The webhook_url must be an absolute https URL, and the signed URL in the payload is an hour old the moment you receive it, so download promptly or re-sign with GET /v1/renders/:id.
If you would rather poll than receive, drop webhook_url, pass async: true, and loop:
let render = await galley("/v1/render", { method: "POST", body: { template: "invoice@1", format: "pdf", data: toInvoicePayload(order), async: true },});while (render.status === "queued" || render.status === "processing") { await new Promise((r) => setTimeout(r, 1000)); render = await galley(`/v1/renders/${render.id}`);}if (render.status === "failed") throw new Error(render.error?.message ?? "Render failed");Pinning invoice@1
Section titled “Pinning invoice@1”template: "invoice" resolves to the latest version at the moment of the call. template: "invoice@1" always resolves to version 1. Publishing a new version never edits an old one — invoice@1 keeps rendering exactly as it did, byte for byte.
Pin in anything you ship, for two reasons. The obvious one is that a template change would silently alter documents customers have already seen. The less obvious one is the cache: the template version checksum is part of the cache key, so an unpinned template that moves to version 2 invalidates every cached render at once and you pay to rebuild them.
You can also pass the version separately, which is convenient when it comes from config:
body: { template: "invoice", version: 1, data: … }That is equivalent to invoice@1. A version in the template string wins if you send both.
To see what versions exist, GET /v1/templates/invoice returns the latest version with its JSON Schema, its default options and an example payload that renders correctly.
What next
Section titled “What next”- Generate a PDF invoice from JSON in Python — the same program, standard library only.
- Render API reference — every request field and response field.
- Caching and signed URLs — what goes into the cache key, and how long each thing lives.