Skip to content

Caching and signed URLs

Renders are deterministic. The same template version, the same data and the same options produce the same bytes, so Galley stores the result and hands it back instead of rendering it twice.

The key is a SHA-256 over five lines joined with \n:

galley.render.v1
<template version checksum>
<format, lowercased>
<canonical JSON of data>
<canonical JSON of options>

The template version checksum is itself a SHA-256, over five lines:

galley.template.v1
<engine>
<template source>
<canonical JSON of schema>
<canonical JSON of the template's default options>

So the key covers the exact bytes of the template that will run, not just its name and number.

Before hashing, data and options are serialised canonically:

  • object keys sorted
  • no insignificant whitespace
  • undefined members dropped
  • Date values written as ISO 8601 strings
  • NaN and Infinity written as null
  • array order preserved — arrays are data, not sets

Two payloads that differ only in key order or formatting therefore hash identically:

{"invoice_number":"INV-1042","total":19}
{ "total": 19, "invoice_number": "INV-1042" }

Both canonicalise to {"invoice_number":"INV-1042","total":19} and hit the same cache entry.

ChangeNew key?
Any value in datayes
Adding or removing a field in datayes
Reordering the elements of an array in datayes
Reordering the keys of an object in datano
Whitespace or indentation in the request bodyno
A different format (pdf vs png)yes
jpg vs jpegno — jpg is normalised to jpeg first
Any render option, including scale and marginyes
A template option default, because options are merged before hashingyes
Publishing a new template versionyes — new source means a new checksum
Editing a template version in placeimpossible; versions are immutable
webhook_url, async, or which API key you usedno

Because per-request options are merged over the template version’s defaults before hashing, sending {"page_size": "Letter"} when the template already defaults to Letter produces the same key as sending nothing.

A stored render is reused only when all of these hold:

  • it belongs to the same account — caches are never shared between accounts
  • its status is succeeded
  • it still has a stored object
  • its retention deadline has not passed

Otherwise the request renders normally.

On a hit the API returns the stored render with "cached": true, status 200, and a freshly signed URL. No browser starts, no usage event is written, no quota is consumed, and the spend cap is never even consulted. This is also true for a request that would otherwise have been queued: a cache hit always answers inside the call.

Render the same thing twice
BODY='{"template":"invoice@1","format":"pdf","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}]}}'
curl -sS https://api.galleyrender.com/v1/render \
-H "Authorization: Bearer $GALLEY_API_KEY" \
-H 'content-type: application/json' -d "$BODY" | python3 -c 'import json,sys; r=json.load(sys.stdin); print(r["id"], r["cached"])'
curl -sS https://api.galleyrender.com/v1/render \
-H "Authorization: Bearer $GALLEY_API_KEY" \
-H 'content-type: application/json' -d "$BODY" | python3 -c 'import json,sys; r=json.load(sys.stdin); print(r["id"], r["cached"])'
rnd_7hq2m4x8k1bv False
rnd_7hq2m4x8k1bv True

Same id, because the second call returned the first render.

There is no no_cache flag. To get a genuinely new render, change something the key covers:

  • Publish a new template version. The clean answer when the template changed.
  • Add a nonce to data, for example {"_nonce": "2026-09-16T15:04:05Z"}. The template can ignore it, but the schema has to allow it: a schema with "additionalProperties": false rejects the field with a validation_error.
  • Change an option that does not affect the output you care about, for example scale: 1.0001.

Bear in mind that a forced miss is a billed render.

Stored objects are in Cloudflare R2, keyed by account, month and cache key:

renders/<account public id>/<yyyy>/<mm>/<cache key>.<ext>

for example

renders/acct_9k2pv3n8rc4t/2026/09/4f1cb8e0…d0.pdf

The extension is pdf, png or jpg. Putting the cache key in the path makes writes idempotent: re-rendering the identical job overwrites the identical bytes.

Two clocks, and they measure different things.

Signed URLStored object
Lifetime1 hour (SIGNED_URL_TTL_SECONDS, default 3600)Retention period — 30 days by default, 90 on Growth and above
Where you see itthe signature inside urlthe expires_at field of the render
When it lapsesthe link stops workingthe file is gone for good
How to get a new oneGET /v1/renders/:idre-render

In production, url is an R2 presigned URL: it carries the expiry in its own query string, it needs no API key, and anyone holding it can fetch the file until it expires. On a local or self-hosted deployment running the filesystem driver, the URL instead points at /v1/files/<key>?expires=<unix>&signature=<hmac> on the API itself, which behaves the same way — no key required, dead after the deadline.

Which host answers is an implementation detail and has changed once already. Use the url you were given, whole; never build one by hand from a render id or a storage key.

Get a fresh URL for a render you already paid for
curl -sS https://api.galleyrender.com/v1/renders/rnd_7hq2m4x8k1bv \
-H "Authorization: Bearer $GALLEY_API_KEY" |
python3 -c 'import json,sys; print(json.load(sys.stdin)["url"])'

GET /v1/renders/:id signs a new URL on every call and never re-renders. Use it rather than holding a URL for a long time, and rather than re-rendering to recover one.