Skip to content

Errors

Every error from the API is the same JSON object, whatever the endpoint. It names a stable machine type, a human sentence, and a documentation URL that resolves.

{
"error": {
"type": "validation_error",
"message": "The data payload does not match the template schema.",
"docs_url": "https://galleyrender.com/docs/errors/validation_error",
"errors": [ /* field errors, when there are any */ ],
"details": { /* structured context, when there is any */ }
}
}
FieldTypeAlways presentDescription
error.typestringyesOne of the twelve types below. Branch on this, never on message.
error.messagestringyesOne sentence for a human or a model to read. Not stable — do not parse it.
error.docs_urlstringyeshttps://galleyrender.com/docs/errors/<type>. Agents follow it.
error.errorsarraynoField errors. Present on validation failures and on most malformed requests.
error.detailsobjectnoStructured context specific to the error: what limits applied, what templates do exist, which asset was refused.

Successful responses never carry an error key, and errors never carry a payload alongside. One exception worth knowing: a failed render fetched with GET /v1/renders/:id returns 200 with status: "failed" and this same envelope nested in the render’s error field. The HTTP status describes the request, not the document.

{
"path": "data.line_items[1].quantity",
"message": "data.line_items[1].quantity must be number",
"expected": "number",
"received": "string",
"example": 2
}
FieldTypeAlways presentDescription
pathstringyesDotted path into what you sent, rooted at data (or template, source, schema, body for request-shape problems). Array indices are bracketed.
messagestringyesThe path plus what is wrong with it.
expectedstringyesThe type or constraint it must satisfy: number <= 1, one of ["USD","EUR"], string (format: email), array with at least 1 items, no additional properties.
receivedstringnoWhat was there: missing, null, string, array(3).
exampleunknownnoA value that would be accepted, taken from the schema’s examples, example, default or enum, or synthesised from the type and format.

Entries are deduplicated by path and rule, so one bad field produces one entry per distinct rule it broke, not one per validator pass.

This is enough to repair a payload mechanically: for each entry, replace the value at path with something matching expected, using example as a guide. Do not guess — if the shape is unclear, read the schema with GET /v1/templates/:ref or dry-run with POST /v1/templates/:ref/validate, which costs nothing.

TypeHTTPRetry?Meaning
invalid_request400noThe request itself is malformed.
asset_blocked400noAn image or font URL failed the egress policy.
authentication_error401noKey missing, unknown or revoked.
quota_exceeded402laterTrial ceiling or free-tier allowance reached.
spend_cap_exceeded402laterThe account’s monthly spend cap would be passed.
permission_error403noThe account is not allowed to do this, or a signed link has expired.
not_found404noNo such template, version, render or route on this account.
conflict409noThe name is already taken.
validation_error422after fixingdata does not match the template’s schema.
rate_limited429yes, after Retry-AfterRenders started faster than the plan’s per-minute limit.
render_failed500sometimesThe template or the browser threw.
internal_error500onceOurs.

invalid_request, validation_error, not_found, conflict and asset_blocked describe something about the request that will be just as wrong the second time. Read errors and details, change the request, send it again. A retry loop around these burns your quota and changes nothing.

authentication_error means the key is missing, mistyped or revoked. The API does not distinguish those cases. See Authentication.

Both quota errors carry details.resets_on (or, for a trial, details.next_step). Nothing you do in the next few seconds will help. Surface it, and back off until the period turns over or the account is upgraded. See Spend caps, quotas and metering.

Retry-After is always present and always in seconds; wait at least that long, then use exponential backoff with jitter and a ceiling on attempts. Successful render responses carry X-RateLimit-Remaining, so you can slow down before you are refused. The limit is 60 renders a minute on the free tier and 600 on a paid plan, counted per API key. See rate_limited.

internal_error is worth one retry. render_failed usually is not: if the template threw on a Liquid filter, it will throw again. Retry render_failed only when the message points at something transient, such as a browser timeout.

Node 22+, no dependencies
const RETRY_AFTER_MS = [500, 2000, 8000];
export async function renderWithRetries(body, key, attempt = 0) {
const res = await fetch("https://api.galleyrender.com/v1/render", {
method: "POST",
headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
body: JSON.stringify(body),
});
if (res.ok) return res.json();
const { error } = await res.json();
switch (error.type) {
case "validation_error":
// Mechanically fixable: every entry names a path, a type and an example.
throw Object.assign(new Error(error.message), { fields: error.errors });
case "quota_exceeded":
case "spend_cap_exceeded":
// Do not retry. It resets on error.details.resets_on, or after an upgrade.
throw Object.assign(new Error(error.message), { resetsOn: error.details?.resets_on });
case "rate_limited": {
// Retry-After is authoritative; the backoff table is only a floor.
if (attempt >= RETRY_AFTER_MS.length) throw new Error(error.message);
const after = Number(res.headers.get("retry-after")) * 1000;
const wait = Math.max(after || 0, RETRY_AFTER_MS[attempt]);
await new Promise((r) => setTimeout(r, wait * (0.5 + Math.random())));
return renderWithRetries(body, key, attempt + 1);
}
case "internal_error":
if (attempt >= RETRY_AFTER_MS.length) throw new Error(error.message);
await new Promise((r) => setTimeout(r, RETRY_AFTER_MS[attempt] * (0.5 + Math.random())));
return renderWithRetries(body, key, attempt + 1);
default:
// invalid_request, authentication_error, permission_error, not_found,
// conflict, asset_blocked, render_failed — retrying will not help.
throw Object.assign(new Error(`${error.type}: ${error.message}`), { docsUrl: error.docs_url });
}
}
Seeing one for real
curl -sS -i https://api.galleyrender.com/v1/render \
-H "Authorization: Bearer $GALLEY_API_KEY" \
-H 'content-type: application/json' \
-d '{"format":"pdf"}'
400 Bad Request
{
"error": {
"type": "invalid_request",
"message": "`template` is required.",
"docs_url": "https://galleyrender.com/docs/errors/invalid_request",
"errors": [
{
"path": "template",
"message": "template is required",
"expected": "string — a template name, optionally with a version: `invoice` or `invoice@3`",
"received": "missing",
"example": "invoice@3"
}
]
}
}

Every response carries an x-request-id header, echoed from your own x-request-id if you sent one. Log it. It is the fastest way for us to find what happened, and it is the one thing to include when you email support@galleyrender.com.