Skip to content

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.pdf and qr.eps draw fg and bg, 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. 1F4E79 as 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=transparent draws none. The title stays black, the logo plate white.
  • No change without colors: without fg/bg and 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. After DELETE /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/:id on the same code return 200 once and 404 once. The qr.deleted webhook is sent exactly once, not twice.
  • New: POST and DELETE /v1/codes/:id/logo return 409 (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 simultaneous DELETE /v1/codes/:id/logo both return 200. Details: Logo.

2026-09 · TypeScript SDK 1.2.0

  • New in @qr3/sdk 1.2.0: client.codes.update() accepts appearance and returns the contrast check’s findings as issues on the result; the field is absent when there are none. client.codes.imageUrl() accepts fg, bg and ecc. Every code the API returns (get, list, create, update, batchCreate) carries title and appearance.
  • 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 PATCH to expires_at now 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 returning 410 for 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_at now 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, POST and DELETE /v1/codes/:id/logo answer 404 and leave the deleted code unchanged, as PATCH already 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/:id accepts appearance with foreground_color and background_color (#RRGGBB, the background also transparent). qr.svg and qr.png draw the stored colors without any parameter; a query parameter such as ?fg=000000 still 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 in meta.issues as warning or critical; a transparent background is always critical, never blocked.
  • Merging: fields you leave out keep their stored value, null resets. Concurrent edits to the same code no longer overwrite each other.
  • In every code response: appearance is part of every code response and of the qr.created and qr.updated webhooks. POST, the batch and the import reject it with 422.
  • 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 or transparent) and ecc (error correction L, M, Q, H). SVG and PNG draw the colors; PDF and EPS accept them but draw them only with a later release. ecc applies to all four formats; a logo still forces H.
  • Transparent: bg=transparent returns 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=300 instead 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.pdf and qr.eps now embed a set logo the same way qr.svg and qr.png do (see below) — as an embedded image (PDF via an Image XObject, EPS via an image dictionary in PostScript, needing %%LanguageLevel: 3 only there), centered on the same area, with the same error-correction bump to H. Neither format shipped the logo before; this fixes that.
  • Caching: With a logo set, qr.pdf and qr.eps now also serve Cache-Control: public, max-age=300 instead 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.eps byte-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 viewer role sees status and preview, but none of the three actions.
  • API: POST /v1/codes/{id}/logo (multipart field file; PNG, JPEG or WebP, up to 1 MB, SVG is rejected) adds or replaces a logo; DELETE /v1/codes/{id}/logo removes it again and is idempotent. An oversized image answers 413, an unsupported format 422, the viewer role 403.
  • Output: qr.svg and qr.png embed the logo as actual pixels (512 × 512, normalized) and raise the error correction to H for it. Adding or removing a logo therefore changes the module pattern (M ↔ H); replacing one does not. qr.pdf and qr.eps never 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.svg and qr.png serve Cache-Control: public, max-age=300 instead 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, archiveWorkspace and 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 — archiveWorkspace is a DELETE, importDpps a POST.
  • 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.txt has a ## When to use qr3.app section — 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: initialize against https://mcp.qr3.app/mcp responds with an instructions field (previously: eleven tools with no framing at all). It also states which calls need an API key — initialize and tools/list do not, every tools/call does.
  • Corrections in llms.txt: Four claims were measured against production and did not hold: (1) the Python SDK qr3app is not published on PyPI — the pip install line is gone with no replacement; (2) Accept: text/markdown applies 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}/stats and GET /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/codes and GET /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/users and /v1/webhooks/:id/deliveries. All of them paged on created_at alone; 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_cursor is now an opaque value (base64url) instead of a bare timestamp as well; unparseable cursors return 400, old timestamp cursors are still accepted for now. Exception GET /v1/webhooks/:id/deliveries: there next_cursor stays the id of the last delivery (unknown id → first page). Clients that pass next_cursor back 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_at came back in SQLite’s format without a timezone (2026-08-17 09:00:00), even though the OpenAPI spec promises format: date-time and create (POST) returns the ISO timestamp (2026-08-17T09:00:00.000Z). The same applied to deleted_at/updated_at on soft-delete, to the edit/delete paths of API keys, organizations, workspaces, members, comments and webhooks, and to last_used_at of API keys and last_triggered_at of webhooks. Every write path now stamps ISO 8601 (UTC, T and Z).
  • Impact: Clients that parse updated_at with new 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') writes YYYY-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/codes and GET /v1/dpp paginated on created_at alone. Codes from POST /v1/codes/batch, POST /v1/dpp/batch and the CSV/XLSX import share one timestamp — as soon as a batch was larger than limit (default 20), the second page dropped the remaining rows with that same timestamp. The codes existed and were reachable via GET /v1/codes/:id, but never showed up in the list (dashboard, CLI qr3 list, SDKs, MCP). The cursor is now a keyset over (created_at, id).
  • API change: meta.pagination.next_cursor is 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 return 400 instead 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_at fell on the current day kept authenticating until midnight UTC. Expiry is now compared as a timestamp rather than as a string — an expired key returns 401 immediately.
  • Background: expires_at is 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-keys is 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: the ip_hash is never included in the export.
  • Error behaviour: Also newly documented: the 400 response of request validation — the body is the raw Zod error, not an RFC 7807 problem document, although it is served under the application/problem+json content type.
  • 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 return public_url. The field is only set for files with visibility: 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.
  • 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/codes accept a links array (0–20 entries, http(s), ≤ 2048 chars). Each URL is checked with Google Web Risk; an unsafe URL returns 422. 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 its noindex header.

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/codes alias 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.ts prevents regressions back to confirm() in the QR code delete flow.
  • Dashboard: Short codes in the QR code list are now directly clickable external redirect links. The external-link icon next to e.g. wu3qaa opens https://qr3.app/{shortCode} in a new tab.
  • i18n: Added German and English tooltip copy.
  • Tests: packages/dashboard/tests/dashboard.test.ts protects 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 uses qr3.app/* because Cloudflare Worker routes do not support :code path parameters.
  • Hardening: Non-matching paths pass through to the landing origin, so regular pages like /en/pricing are not blocked by the Redirect Worker.
  • Tests: packages/redirect/tests/unit/redirect.test.ts covers 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 all dpp_scans for the API key’s workspace (active_dpps, scans_by_day, top_dpps with 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_scans table (migration 0011) — separate from scans (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/:dppId with 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 contains eu_compliance + preview.changed_fields.
  • Dashboard: simulator card on the DPP detail page (/dashboard/dpp/:dppId) — chips for DE/AT/FR/IT/ES/NL + custom, status dropdown, Preview EU impact / Save changes / Reset. Non-blocking via Remix useFetcher.
  • 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/validate now also returns eu_compliance — the same validator as GET /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-compliance with compliant / 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: textile category with AGEC mandatory chain (weaving/knitting → dyeing/printing → assembly), per-fiber origin_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/import accepts CSV and XLSX (Worker-compatible via SheetJS xlsx, ~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_csv field of the 201 response; GET /v1/dpp/import/templates/:category?format=csv|xlsx returns ready-made templates for battery and textile.
  • Dashboard: drag-and-drop upload at /dashboard/dpp/import with template proxy and inline CSV download.

Non-breaking — LTS additions

All of the above are additive:

  • Existing POST /v1/dpp/validate clients can ignore the new eu_compliance field without changes.
  • Existing battery flows are unchanged.
  • market_countries is optional and defaults to [].

See API versioning for the breaking-change policy.