Skip to content

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:

Terminal window
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.

render-invoice.mjs
// Node 22+. No dependencies.
// Usage: GALLEY_API_KEY=glr_sk_… node render-invoice.mjs
import { 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;
}

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:

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/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");

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:

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

The script prints:

The payload does not match the invoice schema:
data.line_items[0].quantity: expected number, received string
e.g. 2

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

Run the script a second time without changing anything:

Saved INV-1042.pdf — 1 page(s), 1 billable unit(s), cached: true

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

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");

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.