Skip to content

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/render
Authorization: Bearer glr_sk_…
Content-Type: application/json
FieldTypeRequiredDescription
templatestringyesTemplate name, optionally pinned: invoice or invoice@3. invoice@latest is the same as invoice.
versionnumber | stringnoVersion to pin, if template does not already carry an @. {"template":"invoice","version":3} is exactly invoice@3.
dataobjectnoThe payload the template renders. Validated against that template version’s JSON Schema. Defaults to {}.
formatstringnopdf, 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.
optionsobjectnoRender options, merged over the template version’s own defaults. See below.
webhook_urlstringnoAbsolute URL to POST the finished render to. Setting it always queues the job. Must be https in production.
asyncbooleannotrue forces the queued path even for a small job.
OptionTypeApplies toNotes
page_sizestringpdfLetter, A4, Legal, … Setting it disables preferCSSPageSize, so your @page rule is ignored; leave it unset to let the template decide.
landscapebooleanpdf
marginstring | objectpdf"18mm", or { "top": …, "right": …, "bottom": …, "left": … }. Unset sides default to 0.5in.
print_backgroundbooleanpdfDefaults to true.
width, heightnumberpng, jpgViewport in CSS pixels. Defaults 1200 × 630, clamped to 1–8000 (1–4000 on satori).
scalenumberall0.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_pagebooleanpng, jpgDefaults to true.
qualitynumberjpg1–100, default 85.
backgroundstringsatori pngBackground behind the card, default transparent.
cssstringchromiumExtra CSS appended after the template’s own styles.
on_blocked_assetstringall"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.

200 when the render completed in the request, 202 when it was queued. The body is the same object either way.

FieldTypeDescription
objectstringAlways render.
idstringrnd_…. Use it with GET /v1/renders/:id.
statusstringqueued, processing, succeeded or failed.
templatestringThe resolved reference, always with a version: invoice@3.
formatstringpdf, png or jpg.
enginestringchromium or satori, from the template version.
cachedbooleantrue when this response is a stored object rather than a new render. Only ever true on the call that hit the cache.
urlstring | nullSigned URL, or null until the render succeeds. Expires in an hour.
expires_atstring | nullWhen the stored object stops being retained (ISO 8601). Not the URL expiry.
page_countnumber | nullPages in the PDF; 1 for raster output.
billable_unitsnumber | null1 per PNG/JPG, 1 per PDF page. null until it succeeds.
byte_sizenumber | nullSize of the stored file.
content_typestring | nullapplication/pdf, image/png or image/jpeg.
created_atstringISO 8601.
completed_atstring | nullISO 8601.
errorobject | nullThe error body recorded on a failed render.
batch_idstringPresent only on renders created by a batch.

A render runs inside the request unless one of these is true:

  • async: true
  • webhook_url is set
  • it is a batch item
  • the canonical JSON of data is 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.

Render an invoice shell
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
    }
  }'
Render an invoice javascript
// 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);
Render an invoice python
# 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"])
Render an invoice — no key shell + json
# 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
        }
      }
    }
  }'
200 OK
{
"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
}
Queue a PNG and get 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" }
}'
202 Accepted
{
"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.

TypeStatusWhen
invalid_request400Body is not JSON, template is missing or malformed, format is unsupported, webhook_url is not an absolute https URL.
validation_error422data does not satisfy the template version’s JSON Schema. The body names every bad field.
authentication_error401Missing, unknown or revoked key.
permission_error403The account is suspended; renders are paused.
not_found404No such template on this account, or no such version. details.available_templates lists what does exist.
quota_exceeded402Trial lifetime cap or free-tier monthly allowance reached.
spend_cap_exceeded402The account’s monthly spend cap would be passed.
asset_blocked400A satori template referenced an image URL that failed the SSRF policy.
render_failed500The 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.