Webhooks
Set webhook_url on a render and the worker POSTs the finished render to it. This is the
alternative to polling GET /v1/renders/:id.
When a render queues
Section titled “When a render queues”Only queued renders produce webhooks. A render is queued when any of these is true:
| Condition | Note |
|---|---|
webhook_url is set | Setting a webhook is itself enough to queue the job — a render with a webhook never runs inline. |
async: true | Explicitly asking for the queued path. |
The canonical JSON of data is over 32 KB | RENDER_SYNC_MAX_DATA_BYTES, default 32768 bytes. |
| The render is a batch item | Every batch item is queued, and each one fires its own webhook. |
Two exceptions: satori templates always render inline whatever the payload size, and a
cache hit always answers inline. A cache hit therefore sends no webhook — the
response you already have is the result.
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", "webhook_url": "https://example.com/hooks/galley", "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 }] } }'{ "object": "render", "id": "rnd_2bk9wx4m7q1h", "status": "queued", "template": "invoice@1", "format": "pdf", "engine": "chromium", "cached": false, "url": null, "expires_at": "2026-10-16T14:07:02.551Z", "page_count": null, "billable_units": null, "byte_size": null, "content_type": "application/pdf", "created_at": "2026-09-16T14:07:02.551Z", "completed_at": null, "error": null, "webhook_status": "pending", "webhook_attempts": 0}The URL
Section titled “The URL”webhook_url must parse as an absolute URL, and must use https in production. It is fetched
through the same SSRF-safe egress path as template assets:
http/https only, ports 80 and 443 only, public IP addresses only. A webhook pointed at
localhost, a private range or a cloud metadata address will never be delivered.
A URL that is not absolute is rejected at request time:
{ "error": { "type": "invalid_request", "message": "`webhook_url` must be an absolute https URL.", "docs_url": "https://galleyrender.com/docs/errors/invalid_request", "errors": [ { "path": "webhook_url", "message": "webhook_url must be an absolute https URL", "expected": "string (format: uri)", "received": "/hooks/galley", "example": "https://example.com/hooks/galley" } ] }}The body you receive
Section titled “The body you receive”POST, content-type: application/json, plus the headers in
Verifying the signature.
{ "type": "render.succeeded", "created_at": "2026-09-16T14:07:04.118Z", "data": { "object": "render", "id": "rnd_2bk9wx4m7q1h", "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:07:02.551Z", "page_count": 1, "billable_units": 1, "byte_size": 48213, "content_type": "application/pdf", "created_at": "2026-09-16T14:07:02.551Z", "completed_at": "2026-09-16T14:07:04.118Z", "error": null, "webhook_status": "pending", "webhook_attempts": 1 }}| Field | Type | Description |
|---|---|---|
type | string | render.succeeded or render.failed. |
created_at | string | When the webhook was built, not when the render finished. |
data | object | The serialized render — identical to what GET /v1/renders/:id returns, including a URL signed at delivery time. |
A failure looks the same with type: "render.failed", status: "failed", url: null, and the
reason in data.error:
{ "type": "render.failed", "created_at": "2026-09-16T09:31:44.190Z", "data": { "object": "render", "id": "rnd_6m1kq9w2t7xz", "status": "failed", "template": "report-table@2", "format": "pdf", "engine": "chromium", "cached": false, "url": null, "expires_at": "2026-10-16T09:31:44.002Z", "page_count": null, "billable_units": null, "byte_size": null, "content_type": "application/pdf", "created_at": "2026-09-16T09:31:41.880Z", "completed_at": "2026-09-16T09:31:44.002Z", "error": { "type": "render_failed", "message": "Template failed to render: undefined filter: currncy", "docs_url": "https://galleyrender.com/docs/errors/render_failed", "details": { "stage": "template" } } }}Verifying the signature
Section titled “Verifying the signature”Every delivery is signed with a secret that belongs to your account, so your handler can tell a real delivery from anyone who learned the URL.
| Header | Value |
|---|---|
X-Galley-Signature | sha256=<hex> — HMAC-SHA256 of the raw request body, keyed with your webhook secret. |
X-Galley-Delivery | The render id, rnd_…. The same across retries of the same render. |
X-Galley-Attempt | 1 for the first attempt, up to 5. |
X-Galley-Timestamp | Unix seconds when this attempt was built. |
Get the secret from GET /v1/account. It is shown once, on the first
call that has one to give:
curl -sS https://api.galleyrender.com/v1/account \ -H "Authorization: Bearer $GALLEY_API_KEY"{ "object": "account", "id": "acct_9k2pv3n8rc4t", "webhook_secret": "whsec_kQ9x…", "webhook_secret_prefix": "whsec_kQ9x…", "webhook_signature_header": "X-Galley-Signature", "webhook_signature_scheme": "sha256=<hex hmac-sha256 of the raw request body>"}Store it. A later call returns webhook_secret: null and only the prefix. If you lose it,
POST /v1/account/webhook-secret mints a new one and shows it once — which invalidates the old
one, so change your handler in the same deploy.
Compare with a constant-time comparison, over the raw bytes, before parsing:
import { createHmac, timingSafeEqual } from "node:crypto";
function isFromGalley(rawBody, header, secret) { const expected = Buffer.from(`sha256=${createHmac("sha256", secret).update(rawBody).digest("hex")}`); const actual = Buffer.from(header ?? ""); return expected.length === actual.length && timingSafeEqual(expected, actual);}import hashlib, hmac
def is_from_galley(raw_body: bytes, header: str, secret: str) -> bool: expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, header or "")Retries
Section titled “Retries”Delivery is attempted up to five times with exponential backoff, so a deploy or a short outage on your side does not lose the event.
| Attempt | When |
|---|---|
| 1 | As soon as the render finishes |
| 2 | 30 seconds later |
| 3 | 90 seconds after that |
| 4 | 4.5 minutes after that |
| 5 | 10 minutes after that |
The last attempt lands about a quarter of an hour after the render. Anything that is not a 2xx
is retried: a 4xx, a 5xx, a timeout, a TLS or DNS failure, or a URL the
egress policy refuses. After the fifth the render is marked
failed and nothing more is sent.
The budget is small. An attempt has a 5-second wall-clock budget and reads at most 64 KB of
your response. Redirects are not followed. Answer 2xx immediately and do the work afterwards.
Delivery status
Section titled “Delivery status”The state of delivery is on the render itself, so
GET /v1/renders/:id is enough to diagnose a webhook that never arrived:
webhook_status | Meaning |
|---|---|
null | No webhook_url was given. |
pending | Accepted, and either not yet attempted or waiting on a retry. |
delivered | Your endpoint answered with a 2xx. |
failed | All five attempts were used. |
webhook_attempts counts attempts made.
A handler worth copying
Section titled “A handler worth copying”import { createServer } from "node:http";import { createHmac, timingSafeEqual } from "node:crypto";
const SECRET = process.env.GALLEY_WEBHOOK_SECRET; // whsec_… from GET /v1/account
function verify(raw, header) { const expected = Buffer.from(`sha256=${createHmac("sha256", SECRET).update(raw).digest("hex")}`); const actual = Buffer.from(header ?? ""); return expected.length === actual.length && timingSafeEqual(expected, actual);}
const seen = new Set(); // in real code: a table, keyed on the render id
createServer(async (req, res) => { if (req.method !== "POST" || req.url !== "/hooks/galley") { res.writeHead(404).end(); return; }
const chunks = []; for await (const chunk of req) chunks.push(chunk); const raw = Buffer.concat(chunks);
// Verify the raw bytes before parsing them. if (!verify(raw, req.headers["x-galley-signature"])) { res.writeHead(401).end(); // a non-2xx is retried, which is what you want // if this is a misconfiguration on your side return; }
// Answer inside 5 seconds or the attempt counts as failed and is retried. res.writeHead(204).end();
const event = JSON.parse(raw.toString("utf8")); const id = event?.data?.id; if (typeof id !== "string" || seen.has(id)) return; // at-least-once delivery seen.add(id);
if (event.data.status === "succeeded") { await store(id, event.data.url); // your code; the URL is good for an hour } else { await alert(id, event.data.error); // your code }}).listen(8080);See also
Section titled “See also”- POST /v1/render
- POST /v1/render/batch — one webhook per item
- Security