SDKs and clients
Status
Section titled “Status”There is no official SDK yet. The galley-render names on npm and PyPI are reserved but
nothing has been published to them. If you find a package by that name today, it is not ours — do
not install it.
That is a smaller gap than it sounds. The API is nine JSON endpoints with one bearer header and one error envelope; the clients below are complete, dependency-free, and about a screen each. When packages do ship, they will wrap exactly these calls.
Three ways to talk to Galley in the meantime:
| Route | Good for |
|---|---|
| Paste a small client | 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 |
Node 22 has fetch built in, so this needs nothing installed.
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; }}Python
Section titled “Python”Standard library only — urllib is enough.
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)import urllib.requestfrom galley import Galley, GalleyError
galley = Galley()
try: render = 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 = galley.wait(render["id"])
urllib.request.urlretrieve(render["url"], "invoice.pdf") print(render["page_count"], "page(s),", render["billable_units"], "unit(s), cached:", render["cached"])except GalleyError as err: print(err.type, err, err.docs_url) for e in err.errors: print(" ", e["path"], e["message"], "example:", e.get("example"))With requests
Section titled “With requests”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)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 ten 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 50-render trial 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”When the packages ship they will be announced on llms.txt and this page. Until then,
if you want a signature-compatible head start, keep your own client behind a thin interface: one
request method and one error class, exactly as above.