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-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 operationIdlistCodes, 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 (postV1CodescreateCode). 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.