POST /v1/render
Renders a single document. Small jobs finish inside the request and return 200 with a signed
url; anything queued returns 202 with an id to poll.
POST https://api.galleyrender.com/v1/renderAuthorization: Bearer glr_sk_…Content-Type: application/jsonRequest
Section titled “Request”| Field | Type | Required | Description |
|---|---|---|---|
template | string | yes | Template name, optionally pinned: invoice or invoice@3. invoice@latest is the same as invoice. |
version | number | string | no | Version to pin, if template does not already carry an @. {"template":"invoice","version":3} is exactly invoice@3. |
data | object | no | The payload the template renders. Validated against that template version’s JSON Schema. Defaults to {}. |
format | string | no | pdf, png, jpg or jpeg. jpg is normalised to jpeg internally and reported back as jpg. Defaults to png for satori templates and pdf for everything else. |
options | object | no | Render options, merged over the template version’s own defaults. See below. |
webhook_url | string | no | Absolute URL to POST the finished render to. Setting it always queues the job. Must be https in production. |
async | boolean | no | true forces the queued path even for a small job. |
Options
Section titled “Options”| Option | Type | Applies to | Notes |
|---|---|---|---|
page_size | string | Letter, A4, Legal, … Setting it disables preferCSSPageSize, so your @page rule is ignored; leave it unset to let the template decide. | |
landscape | boolean | ||
margin | string | object | "18mm", or { "top": …, "right": …, "bottom": …, "left": … }. Unset sides default to 0.5in. | |
print_background | boolean | Defaults to true. | |
width, height | number | png, jpg | Viewport in CSS pixels. Defaults 1200 × 630, clamped to 1–8000 (1–4000 on satori). |
scale | number | all | 0.1–3, on both engines. On raster output it becomes a real device scale factor. On PDF it is a CSS zoom, capped at 2. Out-of-range values are clamped, not rejected. |
full_page | boolean | png, jpg | Defaults to true. |
quality | number | jpg | 1–100, default 85. |
background | string | satori png | Background behind the card, default transparent. |
css | string | chromium | Extra CSS appended after the template’s own styles. |
on_blocked_asset | string | all | "fail" (default) or "skip". A refused image or font fails the render with asset_blocked unless you ask to skip it. See Assets. |
Options are part of the cache key, so a different scale is a different render.
Response
Section titled “Response”200 when the render completed in the request, 202 when it was queued. The body is the same
object either way.
| Field | Type | Description |
|---|---|---|
object | string | Always render. |
id | string | rnd_…. Use it with GET /v1/renders/:id. |
status | string | queued, processing, succeeded or failed. |
template | string | The resolved reference, always with a version: invoice@3. |
format | string | pdf, png or jpg. |
engine | string | chromium or satori, from the template version. |
cached | boolean | true when this response is a stored object rather than a new render. Only ever true on the call that hit the cache. |
url | string | null | Signed URL, or null until the render succeeds. Expires in an hour. |
expires_at | string | null | When the stored object stops being retained (ISO 8601). Not the URL expiry. |
page_count | number | null | Pages in the PDF; 1 for raster output. |
billable_units | number | null | 1 per PNG/JPG, 1 per PDF page. null until it succeeds. |
byte_size | number | null | Size of the stored file. |
content_type | string | null | application/pdf, image/png or image/jpeg. |
created_at | string | ISO 8601. |
completed_at | string | null | ISO 8601. |
error | object | null | The error body recorded on a failed render. |
batch_id | string | Present only on renders created by a batch. |
Sync or queued
Section titled “Sync or queued”A render runs inside the request unless one of these is true:
async: truewebhook_urlis set- it is a batch item
- the canonical JSON of
datais larger than 32 KB (RENDER_SYNC_MAX_DATA_BYTES)
satori templates always run inline regardless of payload size, because that path takes
milliseconds. A cache hit always answers inline too, even for a request that would otherwise
have queued.
Examples
Section titled “Examples”A PDF invoice
Section titled “A PDF invoice”curl -sS https://api.galleyrender.com/v1/render \
-H "Authorization: Bearer $GALLEY_API_KEY" \
-H 'content-type: application/json' \
-d '{
"template": "invoice@1",
"format": "pdf",
"options": { "page_size": "Letter", "margin": "0.5in" },
"data": {
"invoice_number": "INV-1042",
"issued_on": "2026-09-16",
"due_on": "2026-10-16",
"seller": { "name": "Galley Render", "email": "billing@galleyrender.com" },
"buyer": { "name": "Acme Robotics", "email": "ap@acme.test" },
"line_items": [
{ "description": "Starter plan, September", "quantity": 1, "unit_price": 19 },
{ "description": "Overage, 3,200 renders", "quantity": 3.2, "unit_price": 4 }
],
"tax_rate": 0.07
}
}' // Node 22+. No dependencies — `fetch` is built in.
const res = await fetch("https://api.galleyrender.com/v1/render", {
method: "POST",
headers: {
authorization: `Bearer ${process.env.GALLEY_API_KEY}`,
"content-type": "application/json",
},
body: JSON.stringify({
template: "invoice@1",
format: "pdf",
options: {
page_size: "Letter",
margin: "0.5in"
},
data: {
invoice_number: "INV-1042",
issued_on: "2026-09-16",
due_on: "2026-10-16",
seller: {
name: "Galley Render",
email: "billing@galleyrender.com"
},
buyer: {
name: "Acme Robotics",
email: "ap@acme.test"
},
line_items: [
{
description: "Starter plan, September",
quantity: 1,
unit_price: 19
},
{
description: "Overage, 3,200 renders",
quantity: 3.2,
unit_price: 4
}
],
tax_rate: 0.07
}
}),
});
const render = await res.json();
if (!res.ok) throw new Error(render.error.message);
console.log(render.url); # Python 3.9+. Standard library only.
import json, os, urllib.request
body = json.dumps({
"template": "invoice@1",
"format": "pdf",
"options": {
"page_size": "Letter",
"margin": "0.5in"
},
"data": {
"invoice_number": "INV-1042",
"issued_on": "2026-09-16",
"due_on": "2026-10-16",
"seller": {
"name": "Galley Render",
"email": "billing@galleyrender.com"
},
"buyer": {
"name": "Acme Robotics",
"email": "ap@acme.test"
},
"line_items": [
{
"description": "Starter plan, September",
"quantity": 1,
"unit_price": 19
},
{
"description": "Overage, 3,200 renders",
"quantity": 3.2,
"unit_price": 4
}
],
"tax_rate": 0.07
}
}).encode()
req = urllib.request.Request(
"https://api.galleyrender.com/v1/render",
data=body,
headers={
"Authorization": f"Bearer {os.environ['GALLEY_API_KEY']}",
"Content-Type": "application/json",
},
)
render = json.load(urllib.request.urlopen(req))
print(render["url"]) # No API key. The first call mints a 50-render trial.
curl -sS https://mcp.galleyrender.com/mcp \
-H 'content-type: application/json' \
-H 'accept: application/json, text/event-stream' \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "render",
"arguments": {
"template": "invoice@1",
"format": "pdf",
"options": {
"page_size": "Letter",
"margin": "0.5in"
},
"data": {
"invoice_number": "INV-1042",
"issued_on": "2026-09-16",
"due_on": "2026-10-16",
"seller": {
"name": "Galley Render",
"email": "billing@galleyrender.com"
},
"buyer": {
"name": "Acme Robotics",
"email": "ap@acme.test"
},
"line_items": [
{
"description": "Starter plan, September",
"quantity": 1,
"unit_price": 19
},
{
"description": "Overage, 3,200 renders",
"quantity": 3.2,
"unit_price": 4
}
],
"tax_rate": 0.07
}
}
}
}' { "object": "render", "id": "rnd_7hq2m4x8k1bv", "status": "succeeded", "template": "invoice@1", "format": "pdf", "engine": "chromium", "cached": false, "url": "https://galley-renders.r2.cloudflarestorage.com/renders/acct_9k2pv3n8rc4t/2026/09/4f1c…d0.pdf?X-Amz-Expires=3600&X-Amz-Signature=…", "expires_at": "2026-10-16T14:02:11.804Z", "page_count": 1, "billable_units": 1, "byte_size": 48213, "content_type": "application/pdf", "created_at": "2026-09-16T14:02:10.119Z", "completed_at": "2026-09-16T14:02:11.804Z", "error": null}A 2× OG card, queued for a webhook
Section titled “A 2× OG card, queued for a webhook”curl -sS https://api.galleyrender.com/v1/render \ -H "Authorization: Bearer $GALLEY_API_KEY" \ -H 'content-type: application/json' \ -d '{ "template": "og-card", "format": "png", "webhook_url": "https://example.com/hooks/galley", "options": { "width": 1200, "height": 630, "scale": 2 }, "data": { "title": "Documents for agents", "subtitle": "JSON in, PDF out" } }'{ "object": "render", "id": "rnd_2bk9wx4m7q1h", "status": "queued", "template": "og-card@1", "format": "png", "engine": "satori", "cached": false, "url": null, "expires_at": "2026-10-16T14:07:02.551Z", "page_count": null, "billable_units": null, "byte_size": null, "content_type": "image/png", "created_at": "2026-09-16T14:07:02.551Z", "completed_at": null, "error": null}Poll GET /v1/renders/rnd_2bk9wx4m7q1h, or wait for the webhook.
Errors
Section titled “Errors”| Type | Status | When |
|---|---|---|
invalid_request | 400 | Body is not JSON, template is missing or malformed, format is unsupported, webhook_url is not an absolute https URL. |
validation_error | 422 | data does not satisfy the template version’s JSON Schema. The body names every bad field. |
authentication_error | 401 | Missing, unknown or revoked key. |
permission_error | 403 | The account is suspended; renders are paused. |
not_found | 404 | No such template on this account, or no such version. details.available_templates lists what does exist. |
quota_exceeded | 402 | Trial lifetime cap or free-tier monthly allowance reached. |
spend_cap_exceeded | 402 | The account’s monthly spend cap would be passed. |
asset_blocked | 400 | A satori template referenced an image URL that failed the SSRF policy. |
render_failed | 500 | The template threw, or the browser did. The recorded render is in details.render. |
A synchronous render that fails returns render_failed with the serialized render under
error.details.render, so you still get the id. A queued render that fails is stored with
status: "failed" and its error in the error field instead.
See also
Section titled “See also”- POST /v1/render/batch — up to 50 in one call
- GET /v1/renders/:id — poll, and re-sign an expired URL
- Caching and signed URLs
- Webhooks