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.
The envelope
Section titled “The envelope”{ "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 */ } }}| Field | Type | Always present | Description |
|---|---|---|---|
error.type | string | yes | One of the twelve types below. Branch on this, never on message. |
error.message | string | yes | One sentence for a human or a model to read. Not stable — do not parse it. |
error.docs_url | string | yes | https://galleyrender.com/docs/errors/<type>. Agents follow it. |
error.errors | array | no | Field errors. Present on validation failures and on most malformed requests. |
error.details | object | no | Structured 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.
Field errors
Section titled “Field errors”{ "path": "data.line_items[1].quantity", "message": "data.line_items[1].quantity must be number", "expected": "number", "received": "string", "example": 2}| Field | Type | Always present | Description |
|---|---|---|---|
path | string | yes | Dotted path into what you sent, rooted at data (or template, source, schema, body for request-shape problems). Array indices are bracketed. |
message | string | yes | The path plus what is wrong with it. |
expected | string | yes | The type or constraint it must satisfy: number <= 1, one of ["USD","EUR"], string (format: email), array with at least 1 items, no additional properties. |
received | string | no | What was there: missing, null, string, array(3). |
example | unknown | no | A 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.
Every type
Section titled “Every type”| Type | HTTP | Retry? | Meaning |
|---|---|---|---|
invalid_request | 400 | no | The request itself is malformed. |
asset_blocked | 400 | no | An image or font URL failed the egress policy. |
authentication_error | 401 | no | Key missing, unknown or revoked. |
quota_exceeded | 402 | later | Trial ceiling or free-tier allowance reached. |
spend_cap_exceeded | 402 | later | The account’s monthly spend cap would be passed. |
permission_error | 403 | no | The account is not allowed to do this, or a signed link has expired. |
not_found | 404 | no | No such template, version, render or route on this account. |
conflict | 409 | no | The name is already taken. |
validation_error | 422 | after fixing | data does not match the template’s schema. |
rate_limited | 429 | yes, after Retry-After | Renders started faster than the plan’s per-minute limit. |
render_failed | 500 | sometimes | The template or the browser threw. |
internal_error | 500 | once | Ours. |
Handling them by class
Section titled “Handling them by class”4xx you caused — fix, do not retry
Section titled “4xx you caused — fix, do not retry”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.
401 — check the credential
Section titled “401 — check the credential”authentication_error means the key is missing, mistyped or revoked. The API does not distinguish
those cases. See Authentication.
402 — stop
Section titled “402 — stop”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.
429 — back off
Section titled “429 — back off”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.
5xx — retry once, carefully
Section titled “5xx — retry once, carefully”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.
A handler worth copying
Section titled “A handler worth copying”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 }); }}curl -sS -i https://api.galleyrender.com/v1/render \ -H "Authorization: Bearer $GALLEY_API_KEY" \ -H 'content-type: application/json' \ -d '{"format":"pdf"}'{ "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" } ] }}Request ids
Section titled “Request ids”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.