Skip to content

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.

Only queued renders produce webhooks. A render is queued when any of these is true:

ConditionNote
webhook_url is setSetting a webhook is itself enough to queue the job — a render with a webhook never runs inline.
async: trueExplicitly asking for the queued path.
The canonical JSON of data is over 32 KBRENDER_SYNC_MAX_DATA_BYTES, default 32768 bytes.
The render is a batch itemEvery 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.

Queue a render with a webhook
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 }]
}
}'
202 Accepted
{
"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
}

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:

400 Bad Request
{
"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"
}
]
}
}

POST, content-type: application/json, plus the headers in Verifying the signature.

POST https://example.com/hooks/galley
{
"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
}
}
FieldTypeDescription
typestringrender.succeeded or render.failed.
created_atstringWhen the webhook was built, not when the render finished.
dataobjectThe 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:

POST — a failure
{
"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" }
}
}
}

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.

HeaderValue
X-Galley-Signaturesha256=<hex> — HMAC-SHA256 of the raw request body, keyed with your webhook secret.
X-Galley-DeliveryThe render id, rnd_…. The same across retries of the same render.
X-Galley-Attempt1 for the first attempt, up to 5.
X-Galley-TimestampUnix 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:

Get the webhook secret
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:

Node 22+, no dependencies
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);
}
Python 3
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 "")

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.

AttemptWhen
1As soon as the render finishes
230 seconds later
390 seconds after that
44.5 minutes after that
510 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.

The state of delivery is on the render itself, so GET /v1/renders/:id is enough to diagnose a webhook that never arrived:

webhook_statusMeaning
nullNo webhook_url was given.
pendingAccepted, and either not yet attempted or waiting on a retry.
deliveredYour endpoint answered with a 2xx.
failedAll five attempts were used.

webhook_attempts counts attempts made.

Node 22+, no dependencies
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);