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 cache key
Section titled “The cache key”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.
Canonical JSON
Section titled “Canonical JSON”Before hashing, data and options are serialised canonically:
- object keys sorted
- no insignificant whitespace
undefinedmembers droppedDatevalues written as ISO 8601 stringsNaNandInfinitywritten asnull- 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.
What changes the key
Section titled “What changes the key”| Change | New key? |
|---|---|
Any value in data | yes |
Adding or removing a field in data | yes |
Reordering the elements of an array in data | yes |
Reordering the keys of an object in data | no |
| Whitespace or indentation in the request body | no |
A different format (pdf vs png) | yes |
jpg vs jpeg | no — jpg is normalised to jpeg first |
Any render option, including scale and margin | yes |
| A template option default, because options are merged before hashing | yes |
| Publishing a new template version | yes — new source means a new checksum |
| Editing a template version in place | impossible; versions are immutable |
webhook_url, async, or which API key you used | no |
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.
What a lookup also requires
Section titled “What a lookup also requires”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.
A hit is free and instant
Section titled “A hit is free and instant”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.
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 Falsernd_7hq2m4x8k1bv TrueSame id, because the second call returned the first render.
Forcing a miss
Section titled “Forcing a miss”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": falserejects the field with avalidation_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.
Where the file lives
Section titled “Where the file lives”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.pdfThe 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.
Signed URLs versus retention
Section titled “Signed URLs versus retention”Two clocks, and they measure different things.
| Signed URL | Stored object | |
|---|---|---|
| Lifetime | 1 hour (SIGNED_URL_TTL_SECONDS, default 3600) | Retention period — 30 days by default, 90 on Growth and above |
| Where you see it | the signature inside url | the expires_at field of the render |
| When it lapses | the link stops working | the file is gone for good |
| How to get a new one | GET /v1/renders/:id | re-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.
Re-signing
Section titled “Re-signing”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.
See also
Section titled “See also”- GET /v1/renders/:id
- Spend caps, quotas and metering
- Template versions — why a new version starts cold