Skip to content

Changelog

2026-11 · Audit log for every change in the workspace

  • New: GET /v1/audit-logs now records every change in the workspace: codes (including batch and import), API keys, webhooks, members, workspaces, the organization, plan changes, files, product passports with their documents, and GS1 identifiers. Each entry names who made the change and, per field, the old and the new value. Keys, secrets and passwords never appear in it. Until now the log held only entries by the platform and for GS1 verification.
  • New: In the dashboard you read the log under Audit log, from Business, filtered by area and action.
  • Changed: Entries are kept for 365 days. The IP pseudonym and the user agent are recorded only for direct API calls and cleared after 90 days.
  • Changed: resource_type accepts only these values now: codes, api_keys, webhooks, workspaces, workspace_members, organizations, billing, files, dpp_passports, dpp_documents, gs1_identifiers, gs1_verification_requests. Any other value returns 400; until now it returned an empty list.

2026-10 · Dashboard stays reachable after archiving or deleting

  • Fixed: If the workspace your account was created in was archived (for example by another admin of your organization), or you deleted your own organization, every dashboard page failed, including the workspace list you would have needed to switch. The dashboard now opens another workspace you may enter: one of your own organization first, otherwise the oldest.
  • Fixed: An API key refused because its workspace is archived or its organization deleted no longer counts as used: last_used_at stays unchanged and the rate limit is not consumed. It gets 401, also when the limit is reached, never 429.

2026-10 · Account deletion: configuration checked before any change, subscriptions with a manual step

  • Changed: DELETE /v1/account checks the configured Stripe prices before it changes anything. If Stripe does not know one of them or refuses the key, the answer is 503 and nothing is changed; until now the answer was 202, with the organization already locked. Details: GDPR & Privacy.
  • New: If a subscription cannot be ended automatically, because it cannot be told apart from another product’s billing, the answer is 202 with status: "awaiting_operator": everything except the organization’s billing record and the user record is erased without waiting; those follow once the qr3 team has ended the subscription by hand, with nothing further needed from you.

2026-10 · Keys of archived workspaces, billing contacts, creating organizations

  • Changed: An API key whose workspace is archived now gets 401 on every request, like a key whose organization was deleted. Until now it kept reading and writing the archived workspace. In the dashboard you keep working in your other workspaces.
  • Changed: GET /v1/organizations and GET /v1/organizations/:id return billing_email, notification_email and stripe_customer_id only to an org_admin and the owner, and to an API key created by one of them. Everyone else gets the three fields as null. Details: Organizations & Workspaces.
  • Changed: POST /v1/organizations is available only to a person signed in to the dashboard, who becomes the owner and gets the organization’s first workspace as org_admin. API keys get 403; an invalid body now gives 422 instead of 400.

2026-10 · Request verification of a GTIN or Company Prefix

  • New: Organization admins request the verification of a GTIN or Company Prefix in the dashboard under GS1, with a proof such as the GS1 license confirmation. The qr3 team approves it or declines it with a reason; every decision arrives by email. A verified identifier binds the public GS1 resolver to your organization. Details: GS1 Identifiers & Onboarding.
  • New: POST /v1/gs1/verifications, GET /v1/gs1/verifications, GET /v1/gs1/verifications/:id and POST /v1/gs1/verifications/:id/withdraw. Submitting and withdrawing are for organization admins only, direct API keys get 403; 409 carries reason (request_pending, already_verified, verified_elsewhere).
  • New: GET /v1/account/privacy and GET /v1/account/export list the requests under gs1_verification_requests.
  • New: DELETE /v1/gs1/identifiers/:id answers 409 (reason: "verified") for a verified identifier. PUT and DELETE /v1/files/:id answer 409 (reason: "gs1_verification_proof") for the proof of a request under review or approved.
  • Changed: when a workspace is archived, its verification requests under review are withdrawn (withdrawn). The proof is free again, and the identifier can be requested again from another workspace.
  • Changed: 409 with reason: "request_pending" says in workspace and request_id where the open request is, if you may open that workspace. New reason workspace_archived when the workspace is archived while the request is being filed.
  • Fixed: POST /v1/gs1/identifiers answered 500 for a removed identifier. Now it comes back (201, unverified); if it is still registered, the answer is 409 with reason already_registered or registered_in_other_workspace.

2026-10 · GTIN registration for batch and import, EU registry submission only for the GTIN holder

  • Changed: POST /v1/dpp/batch and POST /v1/dpp/import (synchronous and as a queued job) now require, like POST /v1/dpp, that the organization has registered every GTIN, itself or through a GS1 Company Prefix it starts with. Otherwise the whole request is refused with 422 (errors/validation, title GTIN Not Registered, the GTINs in gtins, their number in total); nothing is created or queued. Existing passports are not affected. Details: GS1 Identifiers & Onboarding.
  • Changed: POST /v1/dpp/:id/register submits a passport to the EU registry only if its organization holds the GTIN (verified, otherwise the first one; the rule of the public resolver). Otherwise the answer is 409 (errors/conflict, title GTIN Held by Another Organization, the GTIN in gtins); the holder is not named.

2026-10 · A GTIN resolves only to the organization that owns it

  • Changed: The public GS1 resolver only returns passports of the organization that owns the GTIN: the one that verified the GTIN or its GS1 Company Prefix, otherwise the one that has held it the longest without a break; deleted organizations count for neither. Until now the most recently edited passport won when two organizations used the same identifiers. This applies to /01/… in every representation, to /dpp/… and, without authentication, to the QR assets and the image list. Details: GS1 Resolver.
  • New: POST /v1/dpp, POST /v1/dpp/batch and POST /v1/dpp/import answer 409 (errors/conflict, title GTIN Verified by Another Organization, the refused GTINs in gtins, their number in total) when another organization verified the GTIN. Batch and import are refused as a whole. Details: GS1 Identifiers & Onboarding.

2026-10 · NPS and feedback check sign-in and rights before the body, public passport JSON without the internal validation result

  • Changed: POST /v1/nps and POST /v1/feedback check sign-in and rights before the request body. A request without a key now receives 401 (an invalid body used to give 400), read-only keys and the role viewer receive 403, and only a caller who may write sees the validation: an invalid body is answered with 422 and errors/validation, including an errors list with field and message, instead of 400. JSON that cannot be read stays 400.
  • Changed: The JSON representation of the public GS1 resolver (?format=json, Accept: application/json) no longer contains is_valid and validation_errors, the internal validation result of a passport. The authenticated GET /v1/dpp/:id still returns them. Details: GS1 Resolver.

2026-10 · NPS and feedback need write rights, public passport JSON without internal IDs

  • Changed: POST /v1/nps and POST /v1/feedback require write rights like every other writing endpoint: publishable keys (qr3_pk_), read-only keys and the role viewer receive 403. Until now a publishable key, which sits in public client code, could submit an NPS answer or feedback.
  • Changed: The JSON representation of the public GS1 resolver (?format=json, Accept: application/json) no longer contains workspace_id, organization_id, code_id and created_by (the ID of the API key that created the passport). The authenticated GET /v1/dpp/:id still returns them. Details: GS1 Resolver.

2026-10 · Account deletion erases the person, completely

  • Changed: DELETE /v1/account erases the account of the person behind the request — the signed-in person in the dashboard, otherwise the person who created the key — instead of emptying the key’s workspace. Organizations the person owns are deleted with all workspaces, codes, product passports, files and their storage objects, unless another org admin takes them over; memberships in other organizations end, and those teams keep their data. The login and the Stripe customer are deleted too, and a running subscription ends immediately without refund. Details: GDPR & Privacy.
  • New: The answer carries status (completed, or in_progress with 202 for very large organizations, finished automatically within hours) and the counters organizations_deleted, organizations_transferred, memberships_removed and login_deleted. A key without a person behind it receives 409.
  • New: GET /v1/account/privacy and GET /v1/account/export cover every table with data of the account, including the person (account), product passports, document and file metadata, comments, audit entries and API keys; pii_in_export is now true. Erased data stays restorable from database backups for up to 30 days.

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 · Members and workspaces: visibility by workspace

  • Changed: GET /v1/members returns every member of the organization only to org_admin and the owner. Other members get the members of the workspaces they belong to; an API key gets the members of its own workspace.
  • Changed: For an API key, GET /v1/workspaces lists only its own workspace (before: every workspace of the organization). A workspace the caller cannot see answers 404 on GET, PATCH and DELETE /v1/workspaces/:id, like an unknown ID (before: 200 on GET, 403 on PATCH and DELETE). Details: Organizations & Workspaces.
  • Changed: POST /v1/workspaces with an API key requires that the person who created the key is, at the time of the request, an org_admin or the owner; every other key gets 403, before the slug is checked. The slug of an archived workspace now answers 409 instead of 500.
  • Changed: GET /v1/members answers 403 for publishable keys (qr3_pk_), like the export.

2026-10 · Webhooks: stable event ID, delivery at least once

  • Changed: A webhook event now keeps a single id (evt_…) even if the queue hands over the message behind it twice, for example a scan reported by the redirect worker. Before, such a repeat produced a second delivery with a new id and a new X-QR3-Delivery, which a receiver could not recognise as a duplicate; now the repeat finds the existing delivery instead of creating a second one.
  • Changed: The Webhooks page states the delivery guarantee: at least once, not exactly once, so deduplicate with the id of the payload. An attempt that is still pending after one hour is set to failed by a daily cleanup and is delivered after all if its message still arrives.
  • Changed: The Codes page now says that a deletion triggers exactly one qr.deleted event, delivered at least once. The earlier note that the webhook is “sent exactly once” counted events, not deliveries.

2026-10 · Usage data in the privacy tools, 90-day retention for webhook logs

  • New: GET /v1/account/privacy counts the usage statistics and the webhook delivery log in stored_data (dashboard_activity_records, dashboard_document_activity_records, api_operation_records, webhook_delivery_records) and states their retention in data_processing. GET /v1/account/export contains them as aggregates (dashboard_activity_aggregates, api_operation_aggregates, webhook_delivery_aggregates), without user IDs, key IDs, payload or response. Details: GDPR & Privacy.
  • Changed: DELETE /v1/account also deletes the workspace’s usage statistics and its webhook delivery log, including the attempts of deleted webhooks. The workspace’s webhooks are switched off and deleted (soft delete); their URLs receive no further events.
  • Changed: Webhook delivery attempts are deleted after 90 days (the current UTC day plus the 89 preceding days). Until now they were kept without limit. Details: Webhooks.

2026-10 · Account deletion requires delete rights

  • Changed: DELETE /v1/account answers 403 for the viewer and contributor roles and for publishable keys (qr3_pk_) and deletes nothing. Before, any valid key could empty the workspace. POST /v1/account/privacy/consents requires write rights. Secret keys with the default scope are not affected. Details: GDPR & Privacy.
  • Changed: The deletion archives the QR codes, permanently deletes QR code scans and DPP requests and revokes the API keys — but no longer the system-managed keys the dashboard signs in with. Until now it revoked those too, so a deletion by a team member locked the owner out of the dashboard.
  • Changed: GET /v1/account/export answers 403 for publishable keys: the file contains the target URLs and notes of all codes.

2026-10 · DPP requests in the privacy export

  • New: GET /v1/account/export contains dpp_scan_aggregates: per product passport, the number of stored requests, the number of countries, and the first and last request. As with QR scans, IP pseudonyms are never exported.
  • New: GET /v1/account/privacy counts the stored scans in stored_data: scan_records for QR codes, dpp_scan_records for product passports. Until now, scans were missing from the overview entirely. Details: GDPR & Privacy.

2026-10 · TypeScript SDK 1.3.0

  • Fixed in @qr3/sdk 1.3.0: client.account.export() returns the export file as the API sends it (type AccountExport). Entries in codes are raw database rows: is_dynamic is 0 or 1, tags the stored JSON text. Before, the method was typed as a download link (download_url, expires_at) that the API never returned, and it always resolved to undefined.
  • Changed: client.account.deleteAccount() returns the API’s confirmation (deleted, workspace_id, deleted_at, gdpr_note) instead of void.
  • Changed (types): client.account.get() returns what GET /v1/account/me sends: { acting_user_id, is_superadmin, role }. The fields id, email, plan, organization_id, workspace_id and created_at were never populated; plan is available from client.account.usage(). Source compatibility is limited: code that reads the old fields no longer compiles (TS2339) instead of silently getting undefined, and return client.account.deleteAccount() in a function declared as Promise<void> fails with TS2322.
  • Update: npm install @qr3/sdk@latest. Details: GDPR & Privacy.

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 office@qr3.app, 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.