Changelog
Changelog
Curated highlights of recent releases. For complete API versions and the breaking-change policy, see the API versioning & LTS policy.
Detailed changes for individual endpoints: OpenAPI spec and interactive API reference.
2026-10 · Colors in the dashboard
- New: a code’s detail page has a Colors card: foreground and
background as hex values, transparent background, preview through the
real image route. The check is the one
PATCH /v1/codes/{id}runs: critical pairs need a confirmation, pairs below 1.5:1 cannot be saved. - Reset to black on white returns the same bytes in all four formats as without colors. Codes without colors do not change.
- Transparent codes appear on a checkerboard in every dashboard preview. Details: Colors in the dashboard.
2026-10 · Colors in PDF and EPS
- New:
qr.pdfandqr.epsdrawfgandbg, as parameters and as stored colors. Black and every grey are written as grayscale (black plate only), any other color as CMYK in whole percent, e.g.1F4E79as C74 M36 Y0 K53. - Background like the SVG: as soon as a color is chosen, an opaque fill
sits behind the code and its quiet zone, white or
bg.bg=transparentdraws none. The title stays black, the logo plate white. - No change without colors: without
fg/bgand without stored colors, both formats return the same bytes as before. The PDF of a product passport stays uncolored. - Limits: no ICC profile, not PDF/X. Details: Colors in print.
2026-09 · Static codes without a redirect cache, simultaneous deletion
- Changed: static codes are no longer held in the redirect cache. A call
of their short link (
redirect_url) always reads the current state. AfterDELETE /v1/account, a static code therefore stops redirecting on the next call; before, it could for up to 24 hours. A plan change also applies to static codes at once. Account deletion clears dynamic codes as before. Printed static codes contain their target directly and are not affected. - Fixed: two simultaneous
DELETE /v1/codes/:idon the same code return200once and404once. Theqr.deletedwebhook is sent exactly once, not twice. - New:
POSTandDELETE /v1/codes/:id/logoreturn409(errors/conflict) if another request changed the same code’s logo at the same time, and then change nothing. Before, an uploaded logo could stay stored without being used. Two simultaneousDELETE /v1/codes/:id/logoboth return200. Details: Logo.
2026-09 · TypeScript SDK 1.2.0
- New in
@qr3/sdk1.2.0:client.codes.update()acceptsappearanceand returns the contrast check’s findings asissueson the result; the field is absent when there are none.client.codes.imageUrl()acceptsfg,bgandecc. Every code the API returns (get,list,create,update,batchCreate) carriestitleandappearance. - Update:
npm install @qr3/sdk@latest. Python, Go and PHP follow. Details: SDKs & CLI and Store colors on the code.
2026-09 · Expiry changes apply within about a minute
- Fixed: a
PATCHtoexpires_atnow clears the code’s redirect cache. The new expiry thus applies within about a minute instead of up to 24 hours later. Before, a removed or moved expiry kept returning410for up to 24 hours, and an expiry set to now let the redirect continue for up to 24 hours. - Fixed: a code created with
expires_atnow expires on time even when the expiry falls within its first 24 hours. - Fixed: if a code is deleted while its logo is being uploaded or
removed,
POSTandDELETE /v1/codes/:id/logoanswer404and leave the deleted code unchanged, asPATCHalready does. - Fixed: a static code whose short link (
redirect_url) was opened also sits in the redirect cache. Changing, pausing and deleting it now clear that cache too, not only for dynamic codes.
2026-09 · Store colors on the code
- New:
PATCH /v1/codes/:idacceptsappearancewithforeground_colorandbackground_color(#RRGGBB, the background alsotransparent).qr.svgandqr.pngdraw the stored colors without any parameter; a query parameter such as?fg=000000still takes precedence. PDF and EPS draw colors only with a later release. - Contrast check: a pair below 1.5:1 returns
422. Weaker pairs are saved and reported inmeta.issuesaswarningorcritical; a transparent background is alwayscritical, never blocked. - Merging: fields you leave out keep their stored value,
nullresets. Concurrent edits to the same code no longer overwrite each other. - In every code response:
appearanceis part of every code response and of theqr.createdandqr.updatedwebhooks.POST, the batch and the import reject it with422. - No change for existing codes: without stored colors every code returns the same bytes as before. Details: Store colors on the code.
2026-09 · Colors and error correction as image route parameters
- New: All four image routes accept
fg(foreground color),bg(background color ortransparent) andecc(error correctionL,M,Q,H). SVG and PNG draw the colors; PDF and EPS accept them but draw them only with a later release.eccapplies to all four formats; a logo still forcesH. - Transparent:
bg=transparentreturns an SVG without a background and a PNG with a real alpha channel. The surface must be light and keep the quiet zone clear. - Never an error: invalid values are ignored; the image is then exactly the one without the parameter.
- Caching: any effective parameter serves
Cache-Control: public, max-age=300instead of the standard image’s 24 hours. - No change without parameters: every existing code returns the same bytes as before in all four formats.
- Available on every plan. Details: Colors and error correction.
2026-09 · PDF and EPS now embed the logo too
- Extension:
qr.pdfandqr.epsnow embed a set logo the same wayqr.svgandqr.pngdo (see below) — as an embedded image (PDF via anImage XObject, EPS via an image dictionary in PostScript, needing%%LanguageLevel: 3only there), centered on the same area, with the same error-correction bump toH. Neither format shipped the logo before; this fixes that. - Caching: With a logo set,
qr.pdfandqr.epsnow also serveCache-Control: public, max-age=300instead of the usual 24 hours — same as SVG/PNG. - Fallback: If the logo image itself can’t be prepared (a corrupted stored
object, for example), error correction stays at
H, but the reserved area stays empty instead of showing an image — never a server error. - No behavior change without a logo: codes without a logo still return
qr.pdf/qr.epsbyte-identical, with the unchanged 24-hour cache. - Details and print guidance: Logo in the QR code.
2026-09 · Logo in the QR code — dashboard and API
- New: A code can now carry a logo in the middle. In the dashboard, a
code’s detail page gets its own logo card: Choose logo, confirm a
warning with a mandatory checkbox when adding a first logo or removing one
(both change the module pattern), then Upload logo/Remove logo.
Replacing a logo needs no confirmation — only the image changes, not the
pattern. The
viewerrole sees status and preview, but none of the three actions. - API:
POST /v1/codes/{id}/logo(multipart fieldfile; PNG, JPEG or WebP, up to 1 MB, SVG is rejected) adds or replaces a logo;DELETE /v1/codes/{id}/logoremoves it again and is idempotent. An oversized image answers413, an unsupported format422, theviewerrole403. - Output:
qr.svgandqr.pngembed the logo as actual pixels (512 × 512, normalized) and raise the error correction toHfor it. Adding or removing a logo therefore changes the module pattern (M↔H); replacing one does not.qr.pdfandqr.epsnever embed a logo and stay unchanged. An already-printed code keeps working in every case, since the encoded destination does not depend on the logo — reprint only if you want the (new) logo to show on the physical material, and never mix old and new print files. - Caching: With a logo set,
qr.svgandqr.pngserveCache-Control: public, max-age=300instead of the usual 24 hours. Details, limits and print notes: Logo in the QR code.
2026-08 · operationId on all 75 operations, and a when-to-use guide for agents
- Addition: All 75 operations in the OpenAPI spec now carry an
operationId—listCodes,createCode,getCodeStats,archiveWorkspaceand so on. The field was missing throughout, so every generator had to derive the method name from the HTTP method and the path (postV1Codes). Such names are tied to the path and change with every path rework. The shape is identical in all 75 places:list/get/create/update/replace/delete, otherwise the verb of the domain action (validateDpp,registerGs1Identifier,pingWebhook). The verb follows the domain, not the HTTP method —archiveWorkspaceis aDELETE,importDppsaPOST. - Impact on self-generated clients: If you generate your SDK from the spec, your next run produces renamed methods (
postV1Codes→createCode). That is the purpose of the change, but it is a rename inside someone else’s code, so it is called out explicitly here. Paths, parameters, response shapes and status codes are unchanged; the official SDKs, the CLI and the MCP server are unaffected. - Agent entry point:
https://qr3.app/llms.txthas a## When to use qr3.appsection — six jobs instead of six features, plus the sentence stating what qr3.app is not the right tool for. The MCP server now delivers the same information in its handshake:initializeagainsthttps://mcp.qr3.app/mcpresponds with aninstructionsfield (previously: eleven tools with no framing at all). It also states which calls need an API key —initializeandtools/listdo not, everytools/calldoes. - Corrections in
llms.txt: Four claims were measured against production and did not hold: (1) the Python SDKqr3appis not published on PyPI — thepip installline is gone with no replacement; (2)Accept: text/markdownapplies to the homepage and the blog, not to every server-rendered page; (3) the contact address is[email protected], including in the homepage’s JSON-LD; (4)/en/security/is a redirect to an anchor, not a page of its own. Newly linked:docs.qr3.app/en/skills/. - Guardrails: Three assertions in
openapi-spec.test.ts— completeness, uniqueness,lowerCamelCase— each verified by mutation. Nine further tests pin the four corrected claims so they cannot come back. - Known limitation: Two of the 75 operations are described in the spec but answer
404(GET /v1/codes/{id}/statsandGET /v1/account). They received a name here and will lose it again once it is decided whether they get built or dropped.
2026-08 · Plan downgrades and cancellations now affect the limits too
- Fix: On plan changes and cancellations the Stripe webhook only wrote the plan name, not the organization’s four limit columns (
max_workspaces,max_members,max_dynamic_codes,max_scans_per_month). Since enforcement takes the maximum of the stored value and the plan baseline, a downgraded or cancelled organization kept its old, higher limits — the downgrade had no effect. The webhook now snaps the columns to the new plan’s baseline on every real plan change, and to the free baseline on cancellation — exactly like the admin tooling already does. - Behavior: Routine subscription updates (renewal, payment method, proration) still leave the limits untouched; individually granted higher limits survive them unchanged. Only a real plan change resets them to the new plan’s baseline.
- Impact: No API or response-format change. Organizations whose downgrade predates this fix keep the old values until their next plan change or a support adjustment.
2026-08 · Keyset cursor now on every list endpoint
- Fix: The cursor fix for
GET /v1/codesandGET /v1/dpp(see below) is now rolled out to every remaining cursor-paginated list:GET /v1/qr-codes/:id/comments,/v1/workspaces,/v1/gs1/identifiers,/v1/members,/v1/audit-logs,/v1/admin/orgs,/v1/admin/usersand/v1/webhooks/:id/deliveries. All of them paged oncreated_atalone; rows sharing one timestamp (audit entries of a batch operation, webhook retries, member imports) could get lost on the next page. The sort key is now the tuple(created_at, id)everywhere. - API change: On these lists
meta.pagination.next_cursoris now an opaque value (base64url) instead of a bare timestamp as well; unparseable cursors return400, old timestamp cursors are still accepted for now. ExceptionGET /v1/webhooks/:id/deliveries: therenext_cursorstays the id of the last delivery (unknown id → first page). Clients that passnext_cursorback verbatim — dashboard, CLI, SDKs, MCP — need no change. - Impact: No migration required. If entries seemed to be missing while paging one of these lists (e.g. the audit log or a delivery log in the dashboard): they were never gone — the lists now show all of them.
2026-08 · Timestamps after edit and delete are OpenAPI-conformant again
- Fix: After
PATCH /v1/codes/{id},updated_atcame back in SQLite’s format without a timezone (2026-08-17 09:00:00), even though the OpenAPI spec promisesformat: date-timeand create (POST) returns the ISO timestamp (2026-08-17T09:00:00.000Z). The same applied todeleted_at/updated_aton soft-delete, to the edit/delete paths of API keys, organizations, workspaces, members, comments and webhooks, and tolast_used_atof API keys andlast_triggered_atof webhooks. Every write path now stamps ISO 8601 (UTC,TandZ). - Impact: Clients that parse
updated_atwithnew Date(...)(SDKs, CLI, dashboard) read the space-separated form as local time — the CLI showed the time of once-edited codes shifted by the local offset (Vienna: −2 h). That is fixed. A data migration additionally normalizes already-stored values in the old format to ISO, so ordering and comparisons over mixed data are correct. No change to field names or response structure. - Background: Same defect class as the two fixes below (API-key expiry, re-scan cutoff): SQLite’s
datetime('now')writesYYYY-MM-DD HH:MM:SS, every other writer ISO 8601. A source-level guard test now prevents new occurrences.
2026-08 · List pagination no longer loses batch-created codes
- Fix:
GET /v1/codesandGET /v1/dpppaginated oncreated_atalone. Codes fromPOST /v1/codes/batch,POST /v1/dpp/batchand the CSV/XLSX import share one timestamp — as soon as a batch was larger thanlimit(default 20), the second page dropped the remaining rows with that same timestamp. The codes existed and were reachable viaGET /v1/codes/:id, but never showed up in the list (dashboard, CLIqr3 list, SDKs, MCP). The cursor is now a keyset over(created_at, id). - API change:
meta.pagination.next_cursoris now an opaque value (base64url) instead of a bare timestamp. Clients that pass the cursor back verbatim as?cursor=— which is what the dashboard, CLI, all SDKs and the MCP server do — need no change. Old timestamp cursors are still accepted for now; unparseable cursors return400instead of silently serving page one. - Impact: If you saw fewer codes in the list than a batch import created: they were never gone — the list now shows all of them. No migration required.
2026-08 · Security re-scans back on their 24-hour cadence
- Fix: Periodic re-scanning of target URLs and landing-page links (Google Web Risk) skipped codes whose last scan fell on the same calendar day as the 24-hour cutoff — depending on the time of day, the re-scan was delayed by up to another day. The cutoff is now computed in the same ISO format the scan timestamps are stored in.
- Impact: A target URL that turns unsafe after its last scan once again pauses the code within the documented 24-hour window. No API or response-shape changes.
2026-08 · API keys expire at their expiry time
- Fix: An API key whose
expires_atfell on the current day kept authenticating until midnight UTC. Expiry is now compared as a timestamp rather than as a string — an expired key returns401immediately. - Background:
expires_atis stored as an ISO timestamp (2026-08-14T09:00:00Z), while the comparison side produced the space-separated format (2026-08-14 09:00:00). The raw string comparison therefore only happened to be correct while the dates already differed. - Impact: No migration required, and the response shape of
GET /v1/api-keysis unchanged. Unparseable expiry values now count as expired rather than valid.
2026-08 · API reference: tenant management documented
- OpenAPI: The spec — and with it the interactive reference — now documents organizations (incl.
GET /v1/organizations/usage), workspaces, members & roles, and audit logs. - Billing: The plan overview (
GET /v1/billing/plans) is public; checkout (POST /v1/billing/checkout) and the Stripe customer portal (GET /v1/billing/portal) are documented as organization-administrator endpoints. - Scan export: Scan statistics (
GET /v1/codes/{id}/scans) and the raw-data export (…/scans.csv,…/scans.xlsx) are fully documented — including the GDPR note: theip_hashis never included in the export. - Error behaviour: Also newly documented: the
400response of request validation — the body is the raw Zod error, not an RFC 7807 problem document, although it is served under theapplication/problem+jsoncontent type.
2026-08 · Copy public file links
- Dashboard: Public files on a code’s detail page now have a button that copies their public link to the clipboard — ready to use as a QR code’s target URL when a scan should open one specific document instead of the landing page with its file list.
- API: The file endpoints (
/v1/files) additionally returnpublic_url. The field is only set for files withvisibility: public— private files get no public address. - Behaviour: The link needs no sign-in and opens the file directly in the browser. Replacing the file leaves it unchanged, so a code printed with it keeps working. Details: Files & datasheets.
2026-07 · Team roles: contributor without delete & admin-only billing
- New: Member role Contributor (no delete) — creates and edits QR codes, files, and Digital Product Passports, but cannot delete anything and cannot create API keys. All destructive endpoints check the role server-side (
403). - Billing: Plan upgrades and the Stripe customer portal (
POST /v1/billing/checkout,GET /v1/billing/portal) are now reserved for organization administrators — all other roles see a read-only plan overview. - Dashboard: Actions your role doesn’t permit are hidden: a Viewer sees no create, edit, or delete buttons, for example; lists, downloads, and analytics stay visible. Details: Team & roles.
2026-06 · External links on the code landing page
- Landing page: A code’s qr3-hosted landing page can now list external, self-hosted links (
{ label, url }) in addition to, or instead of, uploaded files — for datasheets that live on your own site. - API:
POST/PATCH /v1/codesaccept alinksarray (0–20 entries,http(s), ≤ 2048 chars). Each URL is checked with Google Web Risk; an unsafe URL returns422. An empty array clears all links. - Dashboard: Add, reorder, and remove links on the code detail page.
- Security: Rendered links stay XSS-safe (escaped,
http(s)-only) and the page keeps itsnoindexheader.
2026-04 · Per-code dashboard analytics
- Dashboard: The Analytics button in the QR code list now opens the statistics page for the selected QR code at
/dashboard/codes/{id}. - Routing: The
/dashboard/codesalias still redirects to/dashboard, but it no longer catches detail routes such as/dashboard/codes/{id}. - API: The detail page loads the QR code directly via
GET /v1/codes/:id, so it no longer depends on list pagination limits. - Tests: Regression tests cover the alias redirect and direct code loading.
2026-04 · Dashboard delete dialog for QR codes
- Dashboard: The trash button in the QR code list now opens a custom React dialog instead of a native browser popup.
- Feedback: After deletion, a toast reports success or failure.
- Tests:
packages/dashboard/tests/dashboard.test.tsprevents regressions back toconfirm()in the QR code delete flow.
2026-04 · Dashboard short-link test for dynamic QR codes
- Dashboard: Short codes in the QR code list are now directly clickable external redirect links. The external-link icon next to e.g.
wu3qaaopenshttps://qr3.app/{shortCode}in a new tab. - i18n: Added German and English tooltip copy.
- Tests:
packages/dashboard/tests/dashboard.test.tsprotects the link href, new-tab behavior,noopener noreferrer, and icon against regressions.
2026-04 · Redirect Worker route for dynamic QR codes
- Fix: Dynamic QR codes at
https://qr3.app/{shortCode}are handled by the Redirect Worker again. The production route now usesqr3.app/*because Cloudflare Worker routes do not support:codepath parameters. - Hardening: Non-matching paths pass through to the landing origin, so regular pages like
/en/pricingare not blocked by the Redirect Worker. - Tests:
packages/redirect/tests/unit/redirect.test.tscovers the wildcard route, short-code handling, and origin pass-through.
2026-04 · Workspace-wide DPP scan overview (Q3.4.2)
- New:
GET /v1/workspace/stats/dpp?days=30— aggregates alldpp_scansfor the API key’s workspace (active_dpps,scans_by_day,top_dppswith product name/category). - Dashboard: Home card (
/dashboard) with a 30-day bar chart + top lists — shown alongside QR code cards. - Public: Marketing short link
GET /dpp/dpp_<id>(single path segment) for live demos, alongside/dpp/{gtin}/{serial}.
2026-04 · DPP scan analytics (Q3.4.1)
- New:
GET /v1/dpp/:id/stats?days=30— aggregated scans of the public GS1 resolver per DPP. Fields:total_scans,period_scans,scans_by_day,top_countries,top_devices,top_representations. - New:
dpp_scanstable (migration0011) — separate fromscans(redirect worker). The current implementation pseudonymises IP addresses with HMAC-SHA-256 using a long-lived secret, purpose, and UTC day; original IP addresses never reach D1. - Dashboard: mini-chart card (SVG, no chart library) on
/dashboard/dpp/:dppIdwith 30-day bars + top-3 breakdowns. Empty-state while a DPP is live but has no scans yet.
2026-04 · Live EU compliance simulator (Q3.3.7)
- New:
POST /v1/dpp/:id/validate-update— simulates partial updates stateless (status, market list, …) without persistence. The response containseu_compliance+preview.changed_fields. - Dashboard: simulator card on the DPP detail page (
/dashboard/dpp/:dppId) — chips forDE/AT/FR/IT/ES/NL+ custom, status dropdown, Preview EU impact / Save changes / Reset. Non-blocking via RemixuseFetcher. - Hardening: extracted simulator helpers (
readUpdatePatchFromForm,marketCountriesKey) + 18 new unit tests; bugfix: lone non-ISO input no longer clears the market list.
2026-04 · Live EU compliance preview in the create form (Q3.3.6)
- Changed:
POST /v1/dpp/validatenow also returnseu_compliance— the same validator asGET /v1/dpp/:id/eu-compliance, stateless before save. - Dashboard: preview under the existing validation panel + new save-guard banner above the submit buttons when errors/warnings remain (i18n pluralization DE/EN).
2026-04 · EU validator + textile UI (Q3.3.4 + Q3.3.5)
- New: EU compliance validator with 5 textile rules (
TEXTILE_AGEC_REQUIRED,TEXTILE_MICROPLASTICS_CONSISTENCY,TEXTILE_SVHC_THRESHOLD,TEXTILE_GREENWASHING,TEXTILE_ESPR_READY). - New:
GET /v1/dpp/:id/eu-compliancewithcompliant/espr_ready/issues[]/summary. - Dashboard: EU compliance section on the DPP detail page (summary tiles, grouped issue cards, ESPR-ready badge in the header).
2026-04 · Textile DPP schema (Q3.3.1–Q3.3.3)
- New:
textilecategory with AGEC mandatory chain (weaving/knitting → dyeing/printing → assembly), per-fiberorigin_country+recycled_pct,svhc_substances[], ESPR opt-in (PEF, lifetime, recyclability). - New: base field
market_countries: string[](ISO 3166-1 alpha-2) across all DPP categories — drives FR-specific AGEC rules and the French consumer mandatory notice. - New: consumer HTML template with AGEC microplastics warning box, 3-step origin chain (flag pills), SVHC list, durability and recyclability sections.
- Migration:
0010_dpp_market_countries(D1).
2026-04 · DPP bulk import (Q3.2.1–Q3.2.5)
- New:
POST /v1/dpp/importaccepts CSV and XLSX (Worker-compatible via SheetJSxlsx, ~283 KB gzip bundle). - Scaled: plan-based limit (Free 100 → Enterprise 10k) + chunked
db.batch()of 100 + 5 MB body limit. - New: error report as CSV in the
errors_csvfield of the 201 response;GET /v1/dpp/import/templates/:category?format=csv|xlsxreturns ready-made templates for battery and textile. - Dashboard: drag-and-drop upload at
/dashboard/dpp/importwith template proxy and inline CSV download.
Non-breaking — LTS additions
All of the above are additive:
- Existing
POST /v1/dpp/validateclients can ignore the neweu_compliancefield without changes. - Existing
batteryflows are unchanged. market_countriesis optional and defaults to[].
See API versioning for the breaking-change policy.