Skip to content

SDKs and clients

PackageRegistryStateInstall
galley-rendernpm✅ Published — 0.1.0npm install galley-render
galley-renderPyPI✅ Published — 0.1.0pip install galley-render
n8n-nodes-galley-rendernpm⏳ Not published yet—

Both official clients went live on 2026-09-17: npmjs.com/package/galley-render and pypi.org/project/galley-render. They are the recommended way to call Galley from Node and from Python, and the Node and Python tabs across this site use them.

Beside each is a (no deps) tab with the same call made straight against the REST API — fetch on Node, urllib on Python. Those are not legacy and are not going away. The API is nine JSON endpoints with one bearer header and one error envelope, so a client you write yourself is about a screen, and anyone who cannot add a dependency should still be able to copy something that runs. The packages wrap exactly these calls.

n8n-nodes-galley-render is still not on npm. If you find a package by that name there right now, it is not ours — do not install it. The n8n node moved to its own public repo because npm provenance requires the package’s repository to resolve to the repository that publishes it, and n8n’s verification requires that repository to be public; see ops/listings/n8n.md.

Three ways to talk to Galley:

RouteGood for
The npm or PyPI packageApplication code. Start here.
Generate one from the OpenAPI documentTyped clients, unusual languages, large surfaces
MCPAgents, and anything that would rather call tools than endpoints
Published on PyPI. Python 3.9 or newer.
pip install galley-render

One dependency (httpx), sync and async clients with the same surface, typed responses generated from the API’s own OpenAPI document, py.typed shipped, and 429/5xx retried with exponential backoff and full jitter.

Quickstart
import os
from galley_render import Galley
galley = Galley(api_key=os.environ["GALLEY_API_KEY"]) # the default, if you omit it
render = galley.render(
"invoice@1",
format="pdf",
options={"page_size": "Letter", "margin": "0.5in"},
data={
"invoice_number": "INV-1042",
"issued_on": "2026-09-16",
"seller": {"name": "Galley Render"},
"buyer": {"name": "Acme Robotics"},
"line_items": [{"description": "September", "quantity": 1, "unit_price": 19}],
},
)
print(render.page_count, "page(s),", render.billable_units, "unit(s), cached:", render.cached)
galley.download(render, to_file="invoice.pdf") # signed urls are short-lived; this re-signs

Async is the same thing with await:

AsyncGalley
from galley_render import AsyncGalley
async with AsyncGalley() as galley: # reads GALLEY_API_KEY
render = await galley.render("invoice@1", format="pdf", data=payload)
pdf = await galley.download(render)

The rest of the surface hangs off two namespaces:

Renders and templates
galley.renders.batch([...], webhook_url="https://example.com/hooks/galley")
galley.renders.get("rnd_2bk9wx4m7q1h")
galley.renders.list(limit=25)
galley.renders.wait("rnd_2bk9wx4m7q1h", timeout=120.0) # backs off; raises if it failed
galley.templates.list()
galley.templates.get("invoice@3")
galley.templates.create("delivery-note", source=html, schema=schema)
galley.templates.publish("delivery-note", source=html, message="new header")
galley.templates.validate("invoice@1", data) # free, renders nothing
galley.usage()
galley.account()

start_trial() mints a real trial account — 10 PDF pages or 10 images — through Galley’s MCP server and hands back a key that works everywhere in the package and against the REST API.

10 PDF pages or 10 images, no signup, no card
import os
from galley_render import Galley, start_trial
trial = start_trial(client_id=os.environ["GALLEY_CLIENT_ID"]) # made once: openssl rand -hex 16
galley = Galley(api_key=trial.api_key)

Every failure is a GalleyError carrying the API’s own envelope: a stable type, a docs_url and, for validation, the field path, the expected type, what arrived and a value that would be accepted.

Handling a validation error
from galley_render import GalleyError
try:
galley.render("invoice", data={})
except GalleyError as err:
print(err.type, err.status, err.retryable, err.request_id)
for field in err.errors:
print(field.path, field.message, field.expected, field.received, field.example)

Full reference: the package README on PyPI.

If a dependency is not an option, urllib from the standard library is enough. This is the same client the Python (no deps) tabs use.

galley.py — Python 3.9+, no dependencies
import json
import os
import time
import urllib.error
import urllib.request
BASE = "https://api.galleyrender.com"
class GalleyError(Exception):
def __init__(self, status, body):
err = (body or {}).get("error", {})
super().__init__(err.get("message", f"Galley request failed with {status}"))
self.status = status
self.type = err.get("type", "internal_error")
self.docs_url = err.get("docs_url")
self.errors = err.get("errors", [])
self.details = err.get("details")
class Galley:
def __init__(self, api_key=None, base_url=BASE):
self.api_key = api_key or os.environ["GALLEY_API_KEY"]
self.base_url = base_url
def request(self, method, path, body=None):
data = None if body is None else json.dumps(body).encode()
req = urllib.request.Request(self.base_url + path, data=data, method=method)
req.add_header("authorization", f"Bearer {self.api_key}")
if data is not None:
req.add_header("content-type", "application/json")
try:
with urllib.request.urlopen(req, timeout=60) as res:
return json.loads(res.read() or b"{}")
except urllib.error.HTTPError as e:
raw = e.read()
raise GalleyError(e.code, json.loads(raw) if raw else None) from None
def render(self, **body):
return self.request("POST", "/v1/render", body)
def render_batch(self, renders, webhook_url=None):
return self.request("POST", "/v1/render/batch", {"renders": renders, "webhook_url": webhook_url})
def get_render(self, render_id):
return self.request("GET", f"/v1/renders/{render_id}")
def list_renders(self, limit=25):
return self.request("GET", f"/v1/renders?limit={limit}")
def list_templates(self):
return self.request("GET", "/v1/templates")
def get_template(self, ref):
return self.request("GET", f"/v1/templates/{ref}")
def create_template(self, **body):
return self.request("POST", "/v1/templates", body)
def publish_version(self, name, **body):
return self.request("POST", f"/v1/templates/{name}/versions", body)
def validate(self, ref, data):
return self.request("POST", f"/v1/templates/{ref}/validate", {"data": data})
def usage(self):
return self.request("GET", "/v1/usage")
def wait(self, render_id, interval=1.0, timeout=120.0):
deadline = time.time() + timeout
while True:
render = self.get_render(render_id)
if render["status"] in ("succeeded", "failed"):
return render
if time.time() > deadline:
raise TimeoutError(f"Render {render_id} did not finish in {timeout}s.")
time.sleep(interval)

If requests is already in the project, the transport collapses to a few lines:

requests version of the same transport
import os
import requests
class Galley:
def __init__(self, api_key=None, base_url="https://api.galleyrender.com"):
self.base_url = base_url
self.session = requests.Session()
self.session.headers["authorization"] = f"Bearer {api_key or os.environ['GALLEY_API_KEY']}"
def request(self, method, path, body=None):
res = self.session.request(method, self.base_url + path, json=body, timeout=60)
payload = res.json() if res.content else {}
if not res.ok:
raise GalleyError(res.status_code, payload)
return payload
def render(self, **body):
return self.request("POST", "/v1/render", body)
Published on npm. Node 20 or newer, ESM.
npm install galley-render

Zero runtime dependencies — just the platform’s fetch. Typed from the API’s own openapi.json, a copy of which ships inside the package so the tarball stands alone. 429 and 5xx are retried with exponential backoff and full jitter. Signed URLs are downloaded and re-signed when they expire. Published with npm provenance from this repository’s tagged release workflow.

Quickstart
import { Galley } from "galley-render";
const galley = new Galley({ apiKey: process.env.GALLEY_API_KEY }); // the default, if you omit it
const render = await galley.render({
template: "invoice@1", // pin the version in anything you ship
format: "pdf",
options: { page_size: "Letter", margin: "0.5in" },
data: {
invoice_number: "INV-1042",
seller: { name: "Galley Render" },
buyer: { name: "Acme Robotics" },
line_items: [{ description: "September", quantity: 1, unit_price: 19 }],
},
});
console.log(render.url); // signed, and short-lived
await galley.download(render, { toFile: "invoice.pdf" }); // re-signs it if it has expired

render.cached === true means the deterministic cache answered — the same template version, data, options and format were rendered before, and this call cost nothing.

new Galley({
apiKey: process.env.GALLEY_API_KEY, // default: GALLEY_API_KEY
baseUrl: "https://api.galleyrender.com", // default: GALLEY_BASE_URL, then this
timeoutMs: 60_000, // per attempt, not total
maxRetries: 3, // extra attempts on 429/5xx and connection failures
fetch: globalThis.fetch, // swap in a proxy or an instrumented fetch
headers: {}, // merged into every request
});
// A webhook, `async: true` or a large payload queues the job instead of
// finishing it inline.
const queued = await galley.render({ template: "report", data, async: true });
const done = await galley.renders.wait(queued.id); // polls with backoff
// Or do both in one call, whichever path the API took.
const finished = await galley.renderAndWait({ template: "report", data });
// Up to 50 at a time. A bad item fails alone; the rest still run.
const batch = await galley.renders.batch({
renders: customers.map((c) => ({ template: "statement@4", data: c })),
webhookUrl: "https://example.com/hooks/galley",
});
await galley.templates.list();
const invoice = await galley.templates.get("invoice@3");
invoice.schema; // JSON Schema for `data`
invoice.example; // a payload that renders
await galley.templates.create({
name: "welcome-card",
engine: "satori", // fast PNG path for simple flexbox cards
source: "<div style='display:flex'>{{ name }}</div>",
schema: { type: "object", required: ["name"], properties: { name: { type: "string" } } },
example: { name: "Dana" },
});
await galley.templates.publish("welcome-card", { source: "…", message: "tighter kerning" });
// Free, renders nothing, and returns the same field errors a render would.
const check = await galley.templates.validate("welcome-card", { name: 42 });
if (!check.valid) console.error(check.errors);

startTrial() mints a real trial account — 10 PDF pages or 10 images — through Galley’s MCP server, with no signup and no card, and hands back a key that works everywhere in the package and against the REST API.

import { Galley, startTrial } from "galley-render";
const trial = await startTrial({ clientId: process.env.GALLEY_CLIENT_ID }); // made once: openssl rand -hex 16
console.log(trial.rendersRemaining);
const galley = new Galley({ apiKey: trial.apiKey });

To lift the limit, call the create_account tool on the MCP server with an email, or sign up. The email links to a page that shows who asked and a short request code, and the address’s owner confirms it there. When they confirm a request made with the same random clientId, the trial upgrades in place, so nothing it made is lost.

Every failure is a GalleyError carrying the API’s own envelope: a stable type, a docsUrl and, for validation, the field path, the expected type, what arrived and a value that would be accepted.

import { GalleyError, GalleyConnectionError, GalleyTimeoutError } from "galley-render";
try {
await galley.render({ template: "invoice", data: {} });
} catch (err) {
if (err instanceof GalleyError) {
err.type; // "validation_error"
err.status; // 422
err.retryable; // false
err.requestId; // quote this in a support mail
for (const field of err.errors ?? []) {
console.error(`${field.path}: ${field.message} (expected ${field.expected}, got ${field.received})`);
}
}
}

GalleyConnectionError means no HTTP response at all — DNS, TLS, timeout, abort. GalleyTimeoutError means wait() gave up while the render was still queued; the render is not lost, so poll again or take the webhook.

Full reference: the package README on npm.

If a dependency is not an option, Node 22’s built-in fetch is enough. This is the same client the Node (no deps) tabs use, and it is deliberately still here: the API is nine JSON endpoints with one bearer header and one error envelope, and you should be able to see the whole thing.

galley.js — Node 22+, no dependencies
const BASE = "https://api.galleyrender.com";
export class GalleyError extends Error {
constructor(status, body) {
super(body?.error?.message ?? `Galley request failed with ${status}`);
this.name = "GalleyError";
this.status = status;
this.type = body?.error?.type ?? "internal_error";
this.docsUrl = body?.error?.docs_url;
/** Field errors: [{ path, message, expected, received, example }] */
this.errors = body?.error?.errors ?? [];
this.details = body?.error?.details;
}
}
export class Galley {
constructor(apiKey = process.env.GALLEY_API_KEY, baseUrl = BASE) {
if (!apiKey) throw new Error("Set GALLEY_API_KEY or pass a key.");
this.apiKey = apiKey;
this.baseUrl = baseUrl;
}
async request(method, path, body) {
const res = await fetch(`${this.baseUrl}${path}`, {
method,
headers: {
authorization: `Bearer ${this.apiKey}`,
...(body === undefined ? {} : { "content-type": "application/json" }),
},
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await res.text();
const parsed = text ? JSON.parse(text) : {};
if (!res.ok) throw new GalleyError(res.status, parsed);
return parsed;
}
render(body) {
return this.request("POST", "/v1/render", body);
}
renderBatch(renders, webhookUrl) {
return this.request("POST", "/v1/render/batch", { renders, webhook_url: webhookUrl });
}
getRender(id) {
return this.request("GET", `/v1/renders/${encodeURIComponent(id)}`);
}
listRenders(limit = 25) {
return this.request("GET", `/v1/renders?limit=${limit}`);
}
listTemplates() {
return this.request("GET", "/v1/templates");
}
getTemplate(ref) {
return this.request("GET", `/v1/templates/${encodeURIComponent(ref)}`);
}
createTemplate(body) {
return this.request("POST", "/v1/templates", body);
}
publishVersion(name, body) {
return this.request("POST", `/v1/templates/${encodeURIComponent(name)}/versions`, body);
}
validate(ref, data) {
return this.request("POST", `/v1/templates/${encodeURIComponent(ref)}/validate`, { data });
}
usage() {
return this.request("GET", "/v1/usage");
}
/** Polls a queued render until it finishes. Returns the final render object. */
async wait(id, { intervalMs = 1000, timeoutMs = 120_000 } = {}) {
const deadline = Date.now() + timeoutMs;
for (;;) {
const render = await this.getRender(id);
if (render.status === "succeeded" || render.status === "failed") return render;
if (Date.now() > deadline) throw new Error(`Render ${id} did not finish in ${timeoutMs} ms.`);
await new Promise((r) => setTimeout(r, intervalMs));
}
}
}
Using it
import { writeFile } from "node:fs/promises";
import { Galley, GalleyError } from "./galley.js";
const galley = new Galley();
try {
const check = await galley.validate("invoice@1", { invoice_number: "INV-1042" });
if (!check.valid) {
for (const e of check.errors) console.error(`${e.path}: expected ${e.expected}, got ${e.received}`);
}
let render = await galley.render({
template: "invoice@1",
format: "pdf",
options: { page_size: "Letter", margin: "0.5in" },
data: {
invoice_number: "INV-1042",
issued_on: "2026-09-16",
seller: { name: "Galley Render" },
buyer: { name: "Acme Robotics" },
line_items: [{ description: "September", quantity: 1, unit_price: 19 }],
},
});
if (render.status !== "succeeded") render = await galley.wait(render.id);
const bytes = await fetch(render.url).then((r) => r.arrayBuffer());
await writeFile("invoice.pdf", Buffer.from(bytes));
console.log(`${render.page_count} page(s), ${render.billable_units} unit(s), cached: ${render.cached}`);
} catch (err) {
if (err instanceof GalleyError) {
console.error(err.type, err.message, err.docsUrl);
for (const e of err.errors) console.error(` ${e.path}: ${e.message} (example: ${JSON.stringify(e.example)})`);
} else {
throw err;
}
}

That is the same shape the package has behind a typed surface, which is the point: keeping your own client behind one request method and one error class makes moving to npm install galley-render an import change rather than a rewrite.

The API publishes its own OpenAPI document at https://api.galleyrender.com/openapi.json. For types without a runtime, a full client in another language, or Pydantic models, see OpenAPI.

Types for TypeScript
npx openapi-typescript https://api.galleyrender.com/openapi.json -o galley.d.ts

If the caller is an agent, skip the HTTP client entirely. https://mcp.galleyrender.com/mcp is a stateless Streamable HTTP MCP server exposing the tools — list_templates, get_template, create_template, update_template, validate_data, render, get_render, list_renders, usage, create_account — with descriptions and input schemas attached. It needs no API key to start: the first render mints a trial of 10 PDF pages or 10 images and hands back the token.

Most MCP clients want this
{
"mcpServers": {
"galley-render": {
"type": "http",
"url": "https://mcp.galleyrender.com/mcp",
"headers": { "X-Galley-Api-Key": "glr_sk_…" }
}
}
}

Omit headers entirely to run on the trial. A raw tools/call over curl is in the Quickstart. The MCP endpoint sends CORS headers, so it is also the route for anything running in a browser — the REST API is not callable from page JavaScript.

n8n-nodes-galley-render is the one package still to come. It will be announced on llms.txt and in the table at the top of this page.

Both official clients are done: galley-render reached PyPI and npm on 2026-09-17, and the Python and Node tabs across the docs switched to them at the same time. The (no deps) tabs beside them stay — see the note under the table.