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-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.