SDKs and clients
Status
Section titled “Status”| Package | Registry | State | Install |
|---|---|---|---|
galley-render | npm | ✅ Published — 0.1.0 | npm install galley-render |
galley-render | PyPI | ✅ Published — 0.1.0 | pip install galley-render |
n8n-nodes-galley-render | npm | ⏳ 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:
| Route | Good for |
|---|---|
| The npm or PyPI package | Application code. Start here. |
| Generate one from the OpenAPI document | Typed clients, unusual languages, large surfaces |
| MCP | Agents, and anything that would rather call tools than endpoints |
Python — pip install galley-render
Section titled “Python — pip install galley-render”pip install galley-renderOne 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.
import osfrom 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-signsAsync is the same thing with await:
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:
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()No key yet
Section titled “No key yet”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.
import osfrom galley_render import Galley, start_trial
trial = start_trial(client_id=os.environ["GALLEY_CLIENT_ID"]) # made once: openssl rand -hex 16galley = Galley(api_key=trial.api_key)Errors
Section titled “Errors”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.
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.
Without the package
Section titled “Without the package”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.
import jsonimport osimport timeimport urllib.errorimport 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:
import osimport 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)Node — npm install galley-render
Section titled “Node — npm install galley-render”npm install galley-renderZero 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.
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-livedawait galley.download(render, { toFile: "invoice.pdf" }); // re-signs it if it has expiredrender.cached === true means the deterministic cache answered — the same template version, data,
options and format were rendered before, and this call cost nothing.
Configuration
Section titled “Configuration”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});Queued renders and batches
Section titled “Queued renders and batches”// 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",});Templates
Section titled “Templates”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);No key yet
Section titled “No key yet”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 16console.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.
Errors
Section titled “Errors”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.
Without the package
Section titled “Without the package”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.
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)); } }}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.
Generating a client
Section titled “Generating a client”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.
npx openapi-typescript https://api.galleyrender.com/openapi.json -o galley.d.tsThe MCP route
Section titled “The MCP route”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.
{ "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.
What to watch
Section titled “What to watch”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.