Skip to content

Changelog

Every change that a caller can notice, newest first. A dated entry here means it is live in production, not merged. Anything that can break a working integration is marked breaking and says what to do about it.

Nothing on this page links off-site. If a change needs more than a line, the link goes to the reference page that already documents it.

Lost your key? We’ll email you a link. The dashboard signs in with a key held in your browser and Galley stores only a hash of each key, so losing it used to mean losing the way in unless you had an assistant to read a mailed code back to. Ask for a link at your dashboard under “Lost your key?”, on /dashboard/keys or on /account/new: the page it opens lists the keys on the account — name, when each was made, when each was last used — mints one new key, shows it once, signs the dashboard in on that device, and retires any of the old ones you choose along with the assistants linked with them. The email never contains a key, the link works once and lasts fifteen minutes, and the answer to the request is the same whether or not the address has an account (Authentication). One link is live at a time, so a request made while an earlier one is still good does not send a second email. Asking is rationed — three per address and five per browser in each fifteen minutes, the same fifteen minutes a link lasts — and the 429 carries link_ttl_minutes and, when the address’s share has been spent by somebody else, the way in that this ration does not cover: create_account with the address, the one-time code it mails, link_account, then rotate_key.

An expired sign-in link says it expired. It used to answer 404 not_found with “It has been used” — which was not true, and which reads in the one flow whose job is reassurance as somebody else opened your email. A link past its fifteen minutes now answers 400 invalid_request, “That sign-in link has expired”, whether or not anybody opened it. details carries expired_at, link_ttl_minutes, and what_this_means and next_step saying plainly that nothing has happened to the account and how to ask for another. A link that really was spent, inside its fifteen minutes, still says used.

The sign-in-link ration has its own window, and it is the link’s. details.window_seconds on that 429 is 900, not 3600: the limit now runs in the same fifteen minutes a link lasts, so retry_after_seconds can never be longer than the life of a link, and the message says so. The two used to share the sign-up hour, which could refuse the owner for another forty-five minutes with a number that was wrong about both. create_account’s own 429 is unchanged, byte for byte.

You set your own spend cap. POST /v1/account/spend-cap with {"monthly_spend_cap_usd": 200}, or the control on the dashboard: anything from $0 (never bill me past my plan) to $1,000 a month, with support for anything higher. The cap still bounds overage only, at your plan’s own rate, over your billing period, and free accounts and trials are still uncapped. Raising it emails the address on the account, because a key is the account — to a verified address only, since a trial and a sign-up nobody has confirmed have no address we can write to, and no more than two such emails in a few minutes however often the cap is moved (spend caps).

A second cap email: “it now stands at $X.” Raises inside that few-minute window used to be dropped, so a run of them could leave the one notice you received naming a cap that was no longer yours. A raise that is held back is now owed rather than lost: once the window is quiet the next lifecycle sweep sends one message — “Your Galley Render spend cap was raised” — that names the cap as it stands when it sends, not as it was when the notice was held. However many calls are made, you learn the cap’s true current value, and a loop still buys at most two messages a window. No response body changed.

The dashboard lists recent renders, newest first, with status, format, pages or images, a link to the file while one is live, “file expired” once retention has passed, and a failed render’s own message and cause.

Breaking, if your client id is guessable. A client id that can hold an account has to look like it came from a secure random source, and the check is stricter: a UUID must be a version 4 UUID (what crypto.randomUUID() and uuidgen produce), and an id that is mostly a word, a year, a run like 0123456789abcdef or a repeat of itself is refused wherever it was accepted before. Ids from openssl rand -hex 16, crypto.randomUUID() and 16 or more random bytes of base64url are unaffected — a million of each were checked. If a client stops resolving, generate a new id and link_account again (MCP). The same check got looser in one place: the distinct-character floor for base64url ids is 8 rather than 12, which fixes a false refusal of about 1 random 22-character id in 27,000 (37 of a million had 11 or 12 distinct characters). Nothing that half accepted before is refused now.

Breaking, on an account that started as a keyless trial before 2026-09-21. A trial’s key was a working key before anybody proved the address, so anything minted or linked with it — a link key and its binding from link_account, a key from POST /v1/account/keys — was issued before the account was proved. Every credential created before verified_at on such an account is refused now, not only the trial key. The account’s own keys, collected at or after the owner’s verification, are untouched. If you are shut out, ask for a sign-in link above. When the credential that goes inert is an assistant’s binding rather than a key, that client does not start erroring: it drops back to a keyless trial, with the trial’s limits. Bind it again with link_account from a client holding one of the account’s current keys.

create_account no longer describes an account it did not create. When the address already has a verified account, create_account — and POST /v1/internal/accounts behind it — answers status: "existing_account" with account: null, where it used to return the account object: the public id, the name, the plan and the timestamps, for any address typed exactly. Whoever asked has proved nothing at that point, and the public id is the token GET /v1/signup/status reads, so the answer now says only that a one-time code went to the address on file. status: "declined" answers account: null for the same reason. Every other field of the response is unchanged, and the codes and link_code_expires_at you act on are still there.

From an assistant, after you confirm a sign-up, that assistant is already the account. No restart, no header, nothing to re-add: the client that asked is bound the moment you confirm, and the key it hands you on the next create_account is for everything else — the REST API, an SDK, a CI job. A client with no identity of its own is told up front, before you open the email, that the key will be shown on the page instead (Claude Code).

A request body that is not a JSON object is a 400, not a 500. null, [], 1, "x" and true are all valid JSON and none of them is a request, so sending one answered 500 internal_error on POST /v1/render, /v1/render/batch, /v1/templates, /v1/templates/:name/versions, /v1/templates/:ref/validate and the internal routes. They are now 400 invalid_request naming the body. An item of renders that is not an object is reported against that item, as any other bad item is, and the batch still answers 202. Endpoints where sending no body at all is the ordinary call — POST /v1/account/keys, POST /v1/billing/portal — are unchanged.

options.margin is validated before the render starts, and the accepted set has changed. Each value is a non-negative number with an optional unit — in, px, cm, mm, and no others. A value that is not a length ({}, [], true, "auto"), a negative one ("-1in"), or margins larger than the sheet ("1000in") are 400 invalid_request with the field path and an example. They used to be a failed render with a 500 (render options).

Two corrections to what this entry said when it first went up, and two things it left out. All four are live:

  • pt and pc were never units this API could accept. They were listed here and in the reference, and the 400’s own expected string recommended them — but Chromium answers Failed to parse parameter value: 12pt and the render failed with a 422. They are gone from the documented set. Use in, px, cm or mm.

  • A bare number is pixels, not inches. 48, "48", "48px" and "0.5in" are one margin. That is what Chromium has always done with the number; it was this API that called it inches, in the validator and here, while handing the number over untouched — so the value we checked was never the value that printed. Two things follow. A per-side number renders exactly as it did before ({"top": 20} was twenty pixels and still is), but it is no longer refused for being too large: {"top": 50} is fifty pixels, and used to be turned away as fifty inches of nothing. And a number as the whole margin — "margin": 0, "margin": 2 — used to be dropped on the floor and quietly replaced with the 0.5in default; it is honoured now. That moves every top-level bare number that used to render, not one of them: 0, 0.5, 1, 2, 3, 4, 16 all printed half an inch on all four sides and now print that many pixels. None survives unchanged, because the only number that would still print the old default is 48 — and "margin": 48 was a 400, refused as forty-eight inches against the page bound, never a quiet 200. If you wrote "margin": 0.5 and liked what you got, you were getting the default: write "0.5in" to keep it.

  • Every cm and mm margin now prints a hair smaller. Chromium’s own converter rounds a centimetre to 37.8 px and a millimetre to 3.78. Each margin is parsed here now and handed over as its exact equivalent (2.54 cm and 25.4 mm to the inch), so ours is the number that prints: "5cm" was 189.0000 px and is 188.9764, "18mm" was 68.0400 and is 68.0315, and "2.54cm" and "25.4mm" were 96.012 px and are exactly 96. One part in 8,000 — invisible on a page, and the new value is the more correct one — but it is a real change to every PDF with a cm or mm margin, and in the narrow band where the two fall either side of a page edge it changes the answer too: "10.794cm" on Letter was 422 content area is empty and is 200.

  • None of this reaches a cached render. A render’s cache key is built from the template version, the data and the options you sent, and none of those changed — only what the renderer does with them. So if you send the same request as before, you get the same file back, with cached: true, until that render’s own retention expires (seven days on Free and on a trial, longer on a paid plan). The new output appears on the first request that misses the cache, which means the two renderings coexist for a while: an identical "margin": 0.5 can answer differently depending on whether it hit (caching).

margin: ".5in" answered 400 for a few hours and does not any more. A leading-dot decimal is an ordinary CSS length and Chromium renders it; so do "+1in", "1e2px" and "1 in". All four were 200 before the validator above shipped, 400 for the hours it took to notice, and are 200 again. If a document stopped rendering that evening, this is why, and nothing needs changing on your side. Every accepted value is now parsed once and passed to Chromium in its canonical form, so what was validated is what prints.

A full-page PNG or JPEG is limited to 16,384 device pixels on either side, measured after scale. A larger page is render_failed 422 with cause: "template" saying so, where it used to kill the render process and answer 500. Bound the page with width, height and full_page: false, lower scale, or render a PDF, which paginates instead of making one bitmap.

A bounded capture Chromium cannot make is a 422 too, not a 500. With full_page: false there is no fixed size limit — Chromium captures well past 16,384 pixels on one side — but width: 8000, height: 8000, scale: 3 asks for 24,000 × 24,000 and it refuses. That answered 500 render_failed with cause: "server" for the first few hours after the limit above shipped, from three documented options. It is the caller’s size, so it is a 422 with cause: "template".

A page that crashes its own renderer is 422, not 500. A template that allocates until Chromium’s renderer process dies, or a paper format Chromium does not know, now answers render_failed 422 with cause: "template", like every other failure the template or data caused. Retrying it will not help, which is what a 422 says and a 500 did not (render_failed). This covers a renderer that dies at any point in the render; it covered only one that died while printing at first, so a page that crashed while it was still loading answered 500 for a few hours. It covers both of the things Chromium says when a renderer dies, too — which spelling you got depended on what the render happened to be doing, and one of them was still answering 500.

But a renderer we took away is still ours, and still a 500. If the browser goes while your page is rendering — we deploy, or the instance runs out of memory — Chromium says almost the same thing as a page that crashed itself, and we were reading it as your template’s fault: 422, cause template, retrying will not help. It is ours now, 500 with cause: "server", which is worth a retry. It should also be rarer: a render already running when a deploy begins is now given time to finish before the browser closes, instead of being killed where it stood.

Breaking, if you link an account through a hosted connector. Claude.ai and ChatGPT reach the MCP server from shared addresses and send nothing that tells one of their users from another, so on the shared URL every user of one of them looked like the same caller. An identity worked out from an IP address and a User-Agent now holds a keyless trial and nothing more: it cannot be linked to an account, cannot collect a key and cannot pass create_account’s proof. To use an account from a hosted connector, add Galley with the personal connector URL that Connect generates for you. Anything already linked on the shared URL drops back to the trial until it is re-added that way. The same applies to any client with no key and no client id of its own — Claude Code or Cursor linked without an X-Galley-Client-Id header lose the link too, and need the header or a key. The client’s address is now taken only from the edge, never from X-Forwarded-For (MCP, Authentication).

Breaking, if you signed up from a keyless trial before today. A trial key minted before its account was verified no longer opens the account: it answers 401. Use the key you collected when you signed up, or recover one with a mailed code — link_account, then rotate_key (Authentication).

A verification link now asks before it gives anything away. Opening it changes nothing. The page names who asked — the website, or an assistant using the MCP server, with the request code create_account returned — and the owner answers: yes, give that assistant access; verify for me alone and show my key; or this was not me, which cancels the request. create_account never returns a key for an existing account without proof (a live key on that same account, a confirmed request, or a link made with link_account), and never returns the verification link itself, even when the mail fails. GET /v1/signup/status no longer names an address in any state and gains cancelled.

Input we cannot store is a 400, not a 500, and JSON may nest 128 levels. A NUL character or an unpaired UTF-16 surrogate — anywhere in data, options, webhook_url, a template’s fields, or a template name or version — is refused as 400 invalid_request naming the field, rather than failing inside the database. The refusal set is exactly what PostgreSQL will not store as sent; everything else, including every other control character and the BOM, is stored unchanged. JSON nested deeper than 128 levels is refused the same way.

Breaking for retry logic: a render that fails because of your template or data is a 422, not a 500. render_failed keeps its type and gains details.cause — template for a Liquid error, markup the image engine rejects, a page that never finishes loading, or an unknown page_size; server or storage for our failures, which stay 500. (The print options that were still a 500 — a negative or enormous margin — are a 400 before the render now; see the top of this page.) A 422 is not worth retrying. A value the database cannot store — a NUL character, a lone surrogate — in data, options or webhook_url is refused up front as 400 invalid_request, naming the field (render_failed).

Breaking: an expired render answers 410 retention_expired. From its expires_at on, GET /v1/renders/:id signs no URL and says the file passed its retention, with what to post to render it again. Retention is stamped when a render is made: 7 days on the free plan and the keyless trial, 30 on Solo and Starter, 90 on Growth and Scale. Deleting expired files starts later this week; until then an expired file is kept but no longer served. When a file is deleted, the JSON payload, options, error detail and webhook URL that produced it go with it, and the record keeps only metadata (retention_expired).

Templates you create now count against a limit: 3 on free, 10 on Solo, 25 on Starter, none on Growth and Scale. The 43 starter templates never count. Creating one past the limit is a 403 plan_required with feature: "templates"; nothing you already have is removed or disabled (plan_required).

The spend cap counts overage only. Pages and images your plan includes never count toward it; overage is priced at your plan’s own rate and counted over your Stripe billing period, not the calendar month. Free and trial accounts have no spend cap — the quota already stops them. On a paid account period in GET /v1/usage is now the start of the billing period as a timestamp (for example 2026-09-20T14:02:11Z) rather than 2026-09. Annual plans include twelve months of the allowance and cap overage at twelve times the monthly cap (Spend cap, GET /v1/usage).

Allowances are stated in PDF pages and images. Nothing about billing changed: a PDF page has always counted 2.5 times an image. Solo is 200 PDF pages or 500 images a month, and every page that states an allowance now says so (Pricing).

last_used_at on a key is recorded. It never was before, so a null on a key made before today means unknown, not unused.

A keyless trial that signs up is no longer refused on its first real render. A trial and the account it becomes are one row, on purpose — the templates and renders made during the trial survive the upgrade — and so did the trial’s renders, which were still sitting in the month’s total when the free allowance was checked. Ten trial renders against a five-render tier meant the first render after verifying came back quota_exceeded. The units spent before verification are credited now, and GET /v1/usage and GET /v1/account report free_renders_used, free_renders_remaining and trial_units_credited from the same place, so the dashboard cannot show a number the next render will not honour. The keyless trial is ten renders again too: it had been answering to the monthly tier, which was never meant to apply to it (GET /v1/usage, quota_exceeded).

Two new endpoints, both GET, both keyless. GET /v1/library returns the starter library as one cacheable document — every template’s name, title, description, engine, format, page count, packs, preview URLs and example payload, optionally narrowed with ?pack=. GET /v1/signup/status?token= answers pending, verified or issued for one sign-up, so a tab waiting on a verification link can finish by itself rather than asking you to press something again; it hands out no key and no token in any state, names no address until that address has been proved, is never cached, and is rationed separately so polling it does not spend the create_account allowance. Both are read-only, both advertise GET, OPTIONS and nothing else, and both are in the spec (OpenAPI reference).

The sign-up page moved from /signup to /account/new, and /signup now 301s there, so existing links keep working. The new page takes one address, mails the link, and then finishes itself — no second press and nothing to relabel. The website around it is rebuilt on two doors: the home page opens on a job picker that goes from landing to a document of your own in two clicks, /make is the same picker as a catalogue, and /build leads with connecting an assistant and then the endpoint, the SDKs and how to get a key (Connect an assistant).

A sign-up that starts in a browser is now told to go back to the tab it started in. The verification email and the page behind the link both used to end “go back to your agent and run create_account once more” — right for an agent, and a dead end for somebody who had typed their address into a form and left a tab open with the button that finishes the job. Both now point at the waiting tab and carry a link back to the site. Nothing changed for an agent: the wording is chosen by a header the website sets, and every caller that does not set it gets what it always got.

The site is dark by default. A reader who has chosen light keeps light, and the theme control gains an explicit System state for following the operating system. Nothing about the API changed.

Every place the site shows a Galley error now shows the way past it as well: the sentence the API puts in details.next_step, and the address it names, as a link. That is the home demo, the four free tools, the template pages and the six /for galleries — each of which used to answer a spent trial with the refusal and nothing to click.

The free tier is 5 renders a month, down from 20 — breaking for free accounts. A free account now gets a quota_exceeded (402) on its sixth billable unit of the month rather than its twenty-first; a render is one PNG or JPG, or one page of a PDF, so a five-page PDF spends the month’s allowance in one call. Existing free accounts still on the old default were moved to 5; an allowance support had set by hand was left where it was. Nothing else changed: the keyless trial is still 10 renders for the lifetime of the token, Solo is still $5 a month for 500, and no paid plan moved. If five a month is not enough, that is what Solo is for (Pricing, quota_exceeded).

The two “you are running out” emails moved with it: the first now arrives with one render left rather than at 80% of the allowance, which at five renders was a sentence about arithmetic nobody had asked for.

Keys can be replaced without writing to support. POST /v1/account/keys mints another key for the account, authenticated by a key you already hold, and returns it once; POST /v1/account/keys/{id}/revoke retires the old one when the new one is deployed. Revoking the last live key is refused, because minting needs a key. Over MCP the pair is the new rotate_key tool. With no key at all, create_account with the verified address still mails a one-time code for link_account, and rotate_key issues a key from there (Authentication, API keys).

Every surface that shows a key now says plainly that it is shown once and cannot be recovered, and what to do about it; the sign-up page adds a copy button and will not let the key panel be dismissed until you confirm you have saved it. No email from Galley contains a key, and the onboarding email no longer suggests the dashboard can issue one — it never could.

One account per mailbox. Breaking for anyone relying on address variants. Email addresses are now compared in canonical form: case-folded, everything from + to @ removed at every domain, and at gmail.com / googlemail.com dots removed and the two domains treated as one. A sign-up whose canonical form matches a verified account returns existing_account and mails a one-time link code to the address on file rather than creating a second account; mail still goes to the address you typed. The per-address create_account throttle counts mailboxes rather than spellings, and disposable-email domains are refused with validation_error naming the domain (Authentication, Terms).

A create_account question that was refused by the throttle now stays refused for the rest of the window. It was possible to ask a refused address a second time and be answered, which made the ration cost two calls per address instead of stopping the question (rate_limited).

The MCP server authenticates on the connection: send Authorization: Bearer glr_sk_… or X-Galley-Key: glr_sk_… and every tool call acts on that account. A key that is presented and cannot work is now refused, naming the header it arrived on, rather than quietly run on the keyless trial — so a typo in a key can no longer spend somebody else’s free renders. A client that cannot set headers links once with link_account, by key or by a one-time code mailed to the account’s own address, and unlink_account revokes it. whoami reports which of the three you are on. create_account with an address that already has a verified account no longer mints a second one: it answers existing_account and mails a link code (Connect an assistant, MCP).

Solo joins the plans at $5 a month, the starter library reaches 43 templates with a full redesign, checkout and the billing portal go live, and — breaking — colour fields in a starter template’s schema now require a hex value or a CSS named colour, so rgb(…) and hsl(…) payloads start coming back as a validation_error naming the field (Accepted colour formats).

Rendered files move to durable object storage, so a signed URL keeps working after a deploy instead of 404ing once the container it was written on goes away (Caching and signed URLs).

Galley was not yet serving traffic anyone could sign up for. There is nothing to report that would be true rather than tidy.