October 8, 2026
This release gives attribute writes a vocabulary — 🐞
The SDK is silent in the console by default.
set_once, add, union, remove, and a data_type pin — and adds two attribute types whose values Usertour assigns, Random A/B and Random number, so a flow can be shown to a stable slice of users without any client-side code. The SDK now recovers its connection on its own and replays the writes it lost, and usertour.setDebug(true) opens its console log. Localized content can translate where its buttons navigate, translations are readable and writable over the API, and themes, attributes, segments and events soft-delete instead of disappearing from history. Under the hood the server moved into a modules layout, the SDK got the test suite it never had, and that suite found four runtime bugs in the start rules, all fixed here.✍️ Attribute write operations
An attribute value sent from the SDK, the REST v2 API or MCP can now be an operation instead of a plain value:{ set_once: value } writes only when the attribute has no value yet; { add: n } adds to a number (negative to subtract); { union: [...] } appends the values a list does not have yet; { remove: [...] } takes values out of a list; { set: value, data_type: 'datetime' } pins the type of an attribute that this write creates. null removes the attribute, and a Date is sent as ISO 8601 UTC. A counter kept with add is never lost to a concurrent write: every user, company and membership write runs under a row lock.The typings had promised this vocabulary since January; the server now implements it with one rule shared by every entry point. The SDK path stays lenient — a value the server refuses is dropped, logged, and reported back — while the v2 API and MCP refuse the whole request with a 400 that names the key. identify(), group() and their update* forms resolve to { rejected: [...] }, listing any key the server refused and why. The legacy subtract / append / prepend spellings still work: the SDK rewrites them and logs a deprecation warning.Acceptance is also wider and more predictable: a numeric string into a Number, a number into a String and any ISO 8601 date-time into a DateTime are stored rather than dropped as type mismatches, and a date-time already in strict UTC is stored as sent, so re-identifying a user no longer makes unchanged values look changed. Reference: https://docs.usertour.io/developers/usertourjs-reference/overview#attributes🎲 Random bucketing attributes
Two new attribute types, Random A/B (A or B) and Random number (1 to a maximum you choose), are assigned by Usertour rather than written by you. The value is derived from a hash of the attribute and the user or company id: fixed when the entity is created, identical on every device, and unchanged when you later raise the maximum. Existing users and companies are backfilled when you create the attribute, so a segment such as rollout ≤ 30 or a start rule on experiment = B is usable at once. Writes to these attributes are refused as system-generated. The attribute dialogs, the condition editor and the segment table all understand the new types. Reference: https://docs.usertour.io/api-reference-v2/attributes#random-bucketing🔌 The SDK reconnects on its own
The SDK used to have two ways to go permanently silent for the rest of a page’s life: a disconnect the server initiated, and a handshake the server refused for a reason that was only temporary. Both now reconnect by themselves, backing off from one second to a minute with jitter, and stop only when the server says the credentials are wrong.identify() and group() calls that failed for a transport reason are replayed on the next connection (with add operations stripped, so nothing is counted twice), a reconnect re-evaluates content once, and wait timers and tracked conditions keep their state across it. On the server side every refused or rate-limited message is answered with false instead of silence, so the SDK never waits on a reply that is not coming.🐞 usertour.setDebug(true)
The SDK is silent in the console by default. usertour.setDebug(true) — or ?usertour_debug=1 on the URL for one page load — turns on a log with one line per decision: what connected, which content was evaluated and why it was or was not shown, which element a step is waiting for. The switch is remembered across page loads until you turn it off. Only two lines print unasked, and both mean nothing will show: the UI failed to initialise, or the connection was rejected for good. Guide: https://docs.usertour.io/developers/usertourjs-reference/advanced/set-debug🌐 Localization: destinations, alt text, and the API
A localized banner whose button sends people to a per-language landing page can now translate that destination: the navigate action on buttons, survey questions, checklist tasks, launchers, resource-center blocks and content-list entries has its own row in the translation editor, as do image alt text and the fallback text of a user-attribute chip.Translation is no longer a dashboard-only workflow. A version’s translation is readable and writable over the v2 API and MCP as flat units (path / source / translation / optional / outdated / kind), the project’s locales are an API resource with their own scopes, and GET /v2/locales serves the same language catalog the dashboard’s picker uses. The dashboard saves translations through the same service, by unit, so two people translating one locale merge instead of overwriting each other. Guide: https://docs.usertour.io/how-to-guides/localization🗑️ Soft delete for themes, attributes, segments and events
All four used to be hard-deleted: deleting a theme stripped it from every historical version, and deleting an attribute a live flow targeted made its conditions silently stop matching. They now soft-delete under one rule — a live surface never references a deleted definition; history may. Deleting something still in use is refused, and the delete dialog shows what uses it before you click, with a live / draft marker and a link to each place. A deleted definition can be restored from the API or MCP, and creating an attribute or event with the same code name restores it. Error reference: https://docs.usertour.io/api-reference-v2/errors🐛 Fixes
- A brand-new flow started right after another was dismissed even with a quiet period (“at least N minutes after any flow”) configured: the quiet period never held for a content the user had no event on yet.
- A start rule combining an event with a browser condition never fired when the event alone satisfied its group: the browser leaf was never tracked.
- Content gated on a condition that had toggled could not start after a reconnect, because the reconnect carried the condition’s stale state.
- Text-input “has any value” was true whenever the input existed, empty or not; it now requires a non-empty value.
- A hover launcher’s tooltip closes when the pointer leaves it; the banner frame no longer shows the browser’s default iframe border on hosts without a CSS reset.
- Two OAuth handshakes in the same second could no longer collide on their
statevalue. - The self-hosted installation snippet’s
ASSETS_URIno longer carries a trailing slash, as the docs say, and the snippet queues asetDebugcall placed before the script loads. identifyAnonymous()’soptsparameter is documented as having no effect: anonymous ids are minted client-side, so a backend can never sign one.
Behaviour changes to check
- Text-input “has any value” now requires a value. A rule that meant “the input is on the page” should use an element “present” condition on the same selector.
- The quiet period now holds for content the user has never seen.
- Segments using
is empty/has any valueon a List attribute never matched anyone before; they match now, so their membership changes after upgrade. - Attribute acceptance is wider (numeric strings into Numbers, numbers into Strings, any ISO 8601 date-time into a DateTime). Inference on first creation stays strict.
- Deletes can be refused. An attribute, segment, theme or event still used by live content answers 409 (E1042 / E1043 / E1030 / E1031); E1030 now means “used by live content” rather than “has recorded events”, so fired events can be deleted. Publishing a version that references a deleted definition answers E1044.
identify()/group()resolve to{ rejected }instead of a boolean; truthiness is unchanged, and an older SDK reads the new acknowledgement as truthy.
Upgrade notes
Usertour Cloud is already running this release. Self-hosted:- One migration, additive (
Localization.deleted, default false, no backfill), and one seed backfill that runs on start: translation rows below schema version 2 get their navigate / alt / chip-fallback clones blanked so the new rows start as real untranslated copy. Expect aLocalized unit stores backfill completedlog line. - A new BullMQ queue,
attribute-backfill, runs in the server process on your existing Redis; a job is enqueued when a bucketing attribute is created or restored. - SDK 0.8.1 ships in the image. The server is backward compatible with pages still running 0.8.0, and the handshake log line now names each connection’s SDK version (
… authenticated for user u1 (sdk 0.8.0)), so the pages that have not reloaded since the deploy can be counted from the logs. - usertour.js 0.0.26 on npm carries the operation typings, the
setDebugstub and the write-result type; it is safe with every bundle version, including ones older than this release. - Nest log contexts that used the old class names change (for example
SharedModule→CommonModule); update any log filters that key on them. - No new environment variable. Rollback is a plain revert: bucketing values already written are inert under the previous server, and SDK 0.8.1 accepts the previous server’s acknowledgements.
🛠️ Under the hood
- Server modules layout (ADR 0015). Every directory under
apps/server/srcmoved intosrc/modules/<name>/;src/holds only the entry points (api/,openapi/,mcp/,web-socket/). A test enforces the import direction. Queue, cron, Redis key, cookie and event names are unchanged. The GraphQL schema and the v2 OpenAPI document are byte-identical before and after. - One rule point for attribute writes (ADR 0017).
resolveAttributeWrites→judgeAttributeWrite→applyAttributeWrites, shared by the socket, v2 and MCP paths; user, company and membership rows are locked withFOR NO KEY UPDATE(so foreign-key inserts never deadlock against them), in the order user → companies by id → memberships; a write that changes nothing takes no lock. Prisma transactions wait up to 10 s and may run 15 s, so the socket queue’s 10 s timeout is always the first limit to fire. - Connection recovery (ADR 0018) is an explicit state machine in the SDK; failed writes are kept in a replay registry with one record per target, keyed by the latest send, replayed once within ten minutes. The server disconnects a socket whose data is gone instead of rebuilding it from a stale handshake, and the throttler marks a refused message rather than throwing.
- Debug logging (ADR 0019) reclassified every SDK message into
debug/info/warn/error/criticalwith a scope per component. - Bucketing (ADR 0020) values are a hash of attribute and entity id, materialised at birth and by the backfill, derived at read time when missing.
- Tests and CI. 65 real-browser widget render checks, a 71-scenario SDK runtime suite against a protocol server the test controls, 26 server rules-conformance scenarios including composite start-rule trees, and a GitHub Actions workflow that lints, type-checks and runs the unit, server e2e and browser suites on every pull request and on pushes to
stagingandmain. - Authorization registry. The endpoint-to-capability test now covers every endpoint, including the identity-verification signing secrets and the audit log, which had drifted out of it.
- Validation. v2 bodies skip class-transformer in the global pipe (zod DTOs carry no metadata for it);
__proto__is refused as an attribute name on every path.
September 18, 2026
This release brings Usertour into your CRM and spreads project administration across more than one person. HubSpot is now a two-way integration — contact and company properties arrive as attributes you can target on, Usertour attributes are written back onto the records, and onboarding milestones land on HubSpot timelines — and the app is listed on the HubSpot Marketplace. Team roles are redrawn as Viewer ⊂ Editor ⊂ Admin ⊂ Owner, with each editor’s publishing limited to the environments you tick. Existing Admin members become Editors on upgrade — read the roles section before you deploy. Transactional emails are redesigned, and the MCP server is in the official MCP registry.
🔗 HubSpot: two-way property sync
Settings → Integrations → HubSpot connects with a single OAuth consent — or start from the Marketplace listing (https://ecosystem.hubspot.com/marketplace/apps/usertour), which signs you in and asks which environment to install into. A HubSpot account connects to one environment at a time.Two object pairs are available: contacts ↔ users and companies ↔ companies. Each pair has a match rule and a field list per direction. Contacts match by email (any email-typed contact property,email by default) or by a contact property holding the id you pass to usertour.identify(); companies match by a property holding your usertour.group() id.Properties coming in — lifecycle stage, plan, industry, owner — become Usertour attributes marked with the HubSpot logo wherever an attribute name appears: attribute settings, user and company detail, the condition builder. They target content like any other attribute. HubSpot owns them: SDK writes to a synced attribute are dropped, API writes are rejected, and the attribute cannot be deleted or retyped while a mapping syncs it. Attributes going out are written to a “Usertour” property group as usertour_user_<name> / usertour_company_<name>, created on demand. Your own HubSpot properties are never a write target, and an empty value on the owning side clears the other side rather than being skipped.Sync links records, it never creates them. A CRM is mostly leads and sign-ups that never activated; materialising them would bury your user list in people who have never opened the product. A link exists only when both records do, unmatched records are counted on the mapping card, and each round re-evaluates them.Run full sync links the records that already exist, and a daily round runs as a backstop. In between, changes move on their own: a HubSpot edit reaches Usertour in about 30 seconds, an attribute change is written back as it happens, and a user identified for the first time is looked up and filled in on the spot rather than waiting for the next round. Incremental sync pulls from HubSpot’s change journal, so no public inbound URL is needed — self-hosted instances on private networks sync too.Sync activity, on the connection’s menu, lists every full round and journal poll with its record counts and, on failure, HubSpot’s own status and message. Write-backs appear in the message log with payload and attempts, on the same retry ladder as webhooks. If the app is uninstalled or the authorizing user removed, the integration switches off at once with a banner naming the cause, and Reconnect is the remedy. Disconnect revokes the grant and keeps your mappings and logs.On Usertour Cloud the HubSpot integration is included from the Growth plan; self-hosted instances are never gated (see the self-hosting notes for the one-time app setup). Guide: https://docs.usertour.io/integrations/hubspot.📌 Onboarding milestones on the HubSpot timeline
The Timeline events card sends eight milestones to HubSpot: flow started, completed and ended; checklist started, completed and task completed; question answered; launcher activated. Each is written to the linked contact and, when the event carries a linked company, to that company as well.They arrive as real HubSpot events, usable in lists, workflows and reports, with no requirement on your HubSpot tier. Each carries what a workflow would filter on: content name and version, step or task name, the question, answer and score, the Usertour user id and the page URL. Timestamps are the moment the event happened, and a retried delivery never produces a duplicate.The set is deliberately the milestones.page_viewed, step-level events and your own custom events are not sent, and there is no “stream everything” switch — a CRM record is not an analytics sink; the analytics integrations are. Deselecting a milestone also stops deliveries already queued. Users with no linked contact produce nothing.👥 Four roles, and a publish whitelist for editors
Until now a project had one person who could invite a teammate, rotate a token, configure a webhook or see why a sync failed — the Owner — while an “Admin” was an editor by another name. Roles are now four, strictly nested:The line between Editor and Admin is drawn by consequence, not by where a page sits in Settings. Anything that only affects the project’s own operation — installing the SDK, wiring a webhook, connecting a CRM — belongs to the Editor, so a builder can finish the job without asking anyone. Anything that changes who gets in or costs money sits above.An Editor publishes (and unpublishes) only to the environments ticked for them when they are invited or their role is changed. The default is none: an editor with nothing ticked edits everything and ships nothing. “Can build but must not ship to production” is one unchecked box, set per member. Admins and the Owner publish anywhere.Alongside it:
- Ownership transfer is its own action, available only to the Owner; the previous owner becomes an Admin. An Admin can invite, re-role and remove anyone except the Owner, and the Owner’s row can no longer be changed through the team endpoints at all.
- Webhook and integration auto-disable emails go to every Admin as well as the Owner.
- SSO auto-provisioning assigns Editor or Viewer only, Editor by default; a just-in-time editor starts with an empty whitelist.
- Personal API keys inherit their owner’s whitelist as a publish ceiling: REST and MCP publish calls outside it are refused with
E1039, and over MCP the refusal names the environments the key may publish to, so an assistant can recover on its own. The key’s own environment scope still governs everything else.
✉️ Redesigned transactional emails
Verify email, reset password, team invite and the two auto-disable notices share one layout: the Usertour wordmark, a single clear action, the full link spelled out on its own line as a fallback, and a footer saying why you are receiving the message. Every email now ships a plain-text alternative alongside the HTML. The wordmark is served by your own instance, so a self-hosted deployment never calls out to us to render an email.🤖 In the official MCP registry
The hosted MCP server is published to registry.modelcontextprotocol.io asio.github.usertour/mcp, which feeds the server directories inside MCP clients. The entry carries no API-key header: the endpoint advertises OAuth discovery, so a client authorizes in the browser instead of asking you to paste a token.🐳 Self-hosting notes
- Seven migrations run on upgrade, including the role migration described above. It is not a no-op: memberships, pending invites and SSO default roles are rewritten. After deploying, open Settings → Team and promote whoever should be an Admin.
- HubSpot needs your own HubSpot app. Redirect URLs and secrets are bound to an app, so upload your copy of
integrations/hubspotto your HubSpot developer account (the directory README has the procedure) and setHUBSPOT_CLIENT_ID/HUBSPOT_CLIENT_SECRET. The callback defaults to${API_URL}/api/integrations/hubspot/oauth/callback, overridable withHUBSPOT_CALLBACK_URL. It must be registered on the app and served by the host the dashboard sends API requests to. Without credentials the HubSpot page shows a pointer to this setup instead of Connect. No traffic is relayed through Usertour Cloud. Redis_DBselects a logical Redis database (0–15) for the cache, the queues and the socket adapter. Unset, it stays 0.- The default email sender reads
Usertour <support@usertour.io>(it was spelledUserTour). SettingEMAIL_SENDERoverrides it as before.
🐛 Fixes
- Any signed-in user could read another project’s plan and usage, or open a checkout or billing-portal session against it, by project id. The subscription endpoints were never attached to the permission guard. Reads now require project membership, and checkout and portal require the Owner.
- Switching a flow’s frequency from Once to Multiple crashed the builder when the flow’s start rules had been written through the API or MCP; the page showed “Something went wrong” and reloading did not help. The missing repeat window is now filled in on read. Delivery to end users was never affected.
- A project could be left with no owner. The role-change endpoint accepted Owner as a target and would re-role the Owner’s own row; the dashboard hid both, the API did not.
- Auto-disable notifications could silently fail to send when a project had many recipients. Each message opened its own SMTP session, and a provider refusing a dozen parallel logins dropped them. Mail now goes through one pooled connection set, and a send during shutdown fails fast instead of hanging the process.
- The activity feed on a company’s page was slow on large workspaces. Its query scanned every event in the table — 660 ms on a million rows — and now resolves through indexes in under a millisecond.
- An integration’s page flashed “not connected” on load before settling on its real state. It holds a skeleton until the environment is known.
- The plan comparison left excluded features blank, which read as missing data. They show a cross, and integrations are listed by name in their own section, next to webhooks.
🛠️ Under the hood
ADR 0013 records the HubSpot design and ADR 0014 the role model; both were revised to match what shipped after their review rounds. The shape behind the sections above:- The sync engine is provider-agnostic below the adapter: mappings, links and sync runs are three tables with no HubSpot assumptions, and the engine is named for object sync rather than for its first provider. The HubSpot app project lives in
integrations/hubspot, outside the pnpm workspace because HubSpot’s CLI builds and deploys it. - Write-backs and timeline events are messages on the outbound ledger, under topics that name the message rather than the recipient —
sync.object.updateandevent.tracked.<codeName>. A write-back carries the field set, not values; values are read at delivery, so retries send current data. The timeline occurrence id is the ledger message id, which is what makes a retry idempotent. - Loops are impossible by construction, through three gates that each suffice alone: inbound applies only listed properties (our own
usertour_*write-backs never re-enter), outbound skips changes whose origin is the provider, and a field cannot sit in both lists of one mapping. - The OAuth handshake binds the callback to the browser that started it: a signed ten-minute state plus an httpOnly
SameSite=Laxtransaction cookie set on the authenticated response, so a forwarded authorize link cannot attach a victim’s HubSpot account to an attacker’s environment. A Marketplace-initiated install finishes inside HubSpot’s frame where that cookie never arrives, so its state is verified by being spent once instead;returnUrlis honoured only for HubSpot app hosts. - Tokens are AES-256-GCM encrypted and refreshed under a single-flight lock with compare-and-set, so a disconnect mid-refresh is never overwritten. A rejected access token is refreshed once, then treated as a revoked grant. Provider errors are stripped of their request before they propagate — the token form carries the app secret.
- Errors are one of four kinds, each with one handling: transient (retry ladder, feeds the breaker), rate limit (wait for
Retry-After, never the breaker), revoked (off at once), refused (a 4xx closes the round immediately with HubSpot’s reason, instead of walking a 25-hour ladder). A round is alive while its heartbeat is fresh, and the stale window outlasts any single wait a job can take; one journal poller runs under a renewed Redis lease so ticks never overlap across instances. - Every dated HubSpot path — OAuth, CRM objects and properties, the webhooks journal — hangs off one
HUBSPOT_API_VERSIONconstant (2026-09). Timeline events stay on/integrators/timeline/v4until HubSpot publishes a dated replacement. - Behaviour is decided only by capability —
roleCan(role, capability)on the server,can(capability)in the web app — and role names are compared only where roles are assigned. TheUSER → EDITORenum change is a hand-writtenALTER TYPE … RENAME VALUE, metadata-only, instead of the drop-and-recreate Prisma generates. - The permission test suite now fails on any unguarded resolver: every resolver class must register the guard, and every query and mutation must be in the capability map or in an explicit, reasoned exemption list. The e2e endpoint matrix moved to four tiers.
- Emails render from typed props in a new
@usertour/emailspackage (React templates replacing mjml + handlebars). The SMTP pool closes in the application-shutdown phase, after the queue workers drain. - The MCP registry entry is published by CI on release tags over OIDC, taking its version from the tag, so a release cannot ship a stale listing. The e2e suite runs on its own Redis database so it no longer consumes a local dev server’s jobs.
September 4, 2026
This release connects Usertour to your analytics stack in both directions, and to Zapier. Every tracked event can now stream to Amplitude, Heap, Mixpanel, PostHog or Segment the moment it happens; cohorts you define in Mixpanel or Amplitude come back as segments you can target flows and checklists at; and a Zapier app triggers Zaps from Usertour events and creates users, companies and events from thousands of other apps. Server-side code gets its own way to record events through the v2 API, and localized content can finally translate where its links point. Under the hood, integrations ride the same outbound ledger webhooks shipped on, and the dormant legacy integration module is gone.
📡 Event streaming to analytics providers
Settings → Integrations is open. Pick a provider, paste its API key (Heap takes an app id), choose the EU region where the provider offers one, and flip Event streaming on. From then on every event tracked in that environment — flow and checklist lifecycle, survey answers, launcher activations, your own custom events, andpage_viewed — is forwarded as it happens. There is no topic picker on purpose: an analytics destination’s contract is the full stream, and per-event routing is what webhook subscriptions are for.Two details make the forwarded data trustworthy. Events carry their event time, not the time we managed to deliver them, and destinations that support a dedup key (Mixpanel’s $insert_id, Amplitude’s insert_id) receive the message id, so a retry can never double-count. Each event also carries its session id under a per-type name — flow_session_id, checklist_session_id, and so on — so a destination can group one user’s runs into sessions instead of seeing a flat event list.Delivery inherits everything webhooks got in v0.9.3: eight attempts across roughly 24 hours, Retry-After honoured, a cooldown for a destination that keeps failing, auto-disable with an owner email after seven days of continuous failure, and a 30-day message log on the integration’s page with the payload, every attempt, and a Send test event button. Fixing a key or region applies to retries already in flight.API keys are AES-256-GCM encrypted at rest and never returned by the API; the dashboard shows the key’s last four characters so you can tell which one is configured. On Usertour Cloud integrations are included from the Starter plan; self-hosted instances are never gated.🔁 Cohort sync from Mixpanel and Amplitude
The reverse direction: a cohort built on product analytics (“power users who never opened the checklist”) becomes a Usertour audience without re-modelling the condition here. Turn on Cohort sync on the Mixpanel or Amplitude integration and you get a receive URL. Paste it into a Mixpanel Custom Webhook or an Amplitude Cohort Webhooks destination and pick the cohorts to sync.Each synced cohort materialises as a read-only segment named after the cohort, marked with the provider’s logo in the segment sidebar, the Users page, and the condition picker — and usable in content targeting exactly like any other segment. Membership follows the provider’s enter and exit batches; Mixpanel’s full-roster exports are treated as a replace, so members who drop out of the cohort drop out of the segment. Renames follow. The same cohort synced from several environments converges onto one segment per project, so targeting that references it works in every environment.Members Usertour has not seen yet are created as bare users — external id only — so the flagship scenario works: circle dormant users in your analytics tool, greet them on their next visit. Cohort sync never writes attributes; the SDK and API stay the single authority on user data. Identity needs no setup when your Mixpaneldistinct_id or Amplitude user_id is the id you pass to identify(); an optional User ID property covers Mixpanel Identity Merge and other divergences. The synced-cohort list shows each cohort’s member count, last sync, a link to its users, and an unresolved count — the one signal that a payload arrived without a usable identity.The receive URL’s token can be rotated (the old URL dies immediately). Deleting the integration releases its segments as ordinary segments rather than deleting them, so live targeting never breaks.⚡ Zapier
Usertour is on Zapier: https://zapier.com/apps/usertour/integrations. Connect with an API token (and your server URL, if self-hosted), then build Zaps from eight triggers — Flow Started, Flow Completed, Flow Ended, Checklist Completed, Survey Question Answered, Launcher Activated, User Created, and a generic Event Tracked with a dropdown of every event your workspace defines, custom ones included. Actions create or update users and companies and track events, with attribute values sent as each attribute’s declared type; Find User and Find Company searches pair with the actions for Zapier’s standard “create if not found” step.Triggers work by subscribing an ordinary webhook in the environment, labelled Managed by Zapier, so Zap deliveries get the same retries, breaker and message log as your own endpoints — leave those webhooks to Zapier; deleting one by hand silently stops its Zap. Zapier also appears as a card on the integrations page with the two setup steps and a link to the guide: https://docs.usertour.io/integrations/zapier.🧾 Track events from your own servers
POST /v2/projects/{projectId}/environments/{environmentId}/events records an event for a user from server-side code — a purchase, a plan change, an import finishing — through the same pipeline as SDK events, so webhooks, analytics destinations and targeting see it identically. Unknown users are created bare, an unknown event name registers its definition on first use, attribute names join the definition’s list, and occurredAt backdates. Built-in event names are refused: analytics must not be forgeable.GET /v2/me introspects the bearer token — its name and the projects and environments it may act on — so integration platforms can validate a pasted token and populate pickers.🌐 Localized link destinations
A link’s destination is per-locale content too — a localized page behind localized anchor text. Inline links in rich text and image click-through links now get a destination row in the localization editor, travel in the CSV exchange, and are skipped by machine translation and missing-translation counts, exactly like image and embed URLs. Destinations that contain a user-attribute chip stay source-managed.🐳 Self-hosting notes
RUN_SEED=falseskips the startup seed on an instance. Multi-instance deployments set it on all but one instance (or run the seed from a one-off job) so the idempotent backfills execute once instead of once per replica. Unset, behaviour is unchanged.- If you front the server with your own reverse proxy, route
/inboundto the API (cohort-sync receive URLs live there) and allow request bodies up to 5 MB on the API location; the bundled nginx already does both. - The legacy integration tables are dropped, not migrated. That module was never reachable — hidden menu entry, dead call site — so any rows in them never took effect.
jsforceleaves the dependency tree.
🐛 Fixes
- A page holding only the public environment token could record built-in events such as
flow_completed. The SDK’s socket channel kept its own event registration and accepted any name, polluting analytics and firing every webhook or Zap on that topic. It now rides the same core as the REST endpoint and refuses built-in names. - Reload buttons on user and company detail pages misbehaved. The activity feed’s reload crashed with a circular-structure error because the click event was passed as query variables; the sessions list emptied itself on refresh and never repopulated. Both keep their rows on screen, and the buttons now show an in-flight spinner.
- A long URL blew the link popover open in the editor, pushing the attribute and delete buttons outside the card. The URL wraps inside the editable instead. Same fix for the image link panel and the page-navigate action input.
- Un-entitled projects flashed all provider cards before the upsell on the integrations page. Skeleton cards hold the layout while entitlement settles.
- An integration running only cohort sync read as “Disabled”. The status badge now considers both capabilities.
🛠️ Under the hood
The integrations surface was rebuilt end-to-end rather than repaired; ADR 0011 records why the legacy module could not be revived (error-swallowing queues that disabled retries, plaintext credentials, dedup keyed on the event name, delivery-time timestamps). ADR 0012 covers cohort sync.- Integrations are a second transport over the outbound ledger:
BIZ_EVENT_TRACKED→ listener → ledger → one delivery queue → pure per-provider adapters. The ledger stores the canonical envelope and the wire body is derived at delivery time, which is what lets a key or region fix apply to in-flight retries and keeps the message log uniform across webhooks and integrations. The retry ladder and reconcile parameters moved tooutbound/delivery-backoff.ts, shared by both transports. - Cohort sync is one provider-agnostic engine behind per-provider entry adapters that normalise payloads into a single batch contract. Writes are set-based and idempotent (
createMany … skipDuplicates, bulk deletes), so a retried or out-of-order batch cannot corrupt state; full-roster rounds carry their state on the mapping row and only reap members their own environment bridged. Processing is synchronous in the request — a failure returns 5xx and the provider retries. Tokens areutin_-prefixed, encrypted at rest with a sha256 lookup column; a bad token 404s, a disabled switch or lapsed plan 503s so the provider keeps retrying rather than pausing permanently. - Custom event registration is one idempotent step outside the event transaction (
registerCustomEvent: definition upsert, reserved-name guard, attribute registration, one retry absorbing concurrent first use), shared by the v2 REST endpoint, the SDK socket channel and the Zapier action. - Localization replaces the “stored value equals live source, so not an override” heuristic with an explicit format stamp on every localized write, plus an idempotent seed-time backfill for rows saved before link units existed.
- The integration catalog moved to
@usertour/constantsand provider types split into analytics and automation unions; the Zapier app lives inintegrations/zapier, deliberately outside the pnpm workspace because the Zapier Platform CLI builds and deploys it. - Test coverage: end-to-end suites for cohort sync (materialisation, idempotent increments, paged replace, cross-environment convergence, read-only enforcement, token rotation, release-on-delete), the Amplitude entry, the track-event route, and built-in-name refusal on the socket channel.
August 22, 2026
This release ships outbound webhooks: tracked events, content publishes, and user or company changes POSTed to an endpoint you control the moment they happen. Every message is signed with a per-endpoint secret, retried across a day-long ladder when your receiver is down, and kept for 30 days with each delivery attempt — so you can see exactly what was sent, what came back, and re-send it. New signups also land on a starting-point screen: build with an AI assistant, or go straight into the dashboard.
🪝 Outbound webhooks
Endpoints live in Settings → Webhooks and belong to one environment — Production and Staging keep separate lists, separate signing secrets, and never see each other’s traffic. Point one at any public HTTPS URL and pick what it should receive from a subscription tree: everything, a whole family (all tracked events, all user changes, all company changes), a group, or a single topic. Families cover topics added later, so subscribing touser today keeps working when a new user topic ships.Available topics are the tracked events your workspace already defines (event.tracked.flow_completed, event.tracked.checklist_task_completed, …), content.published, and the entity-change set — user.created / user.updated / user.deleted and the same three for company. Entity messages carry the full object plus previousAttributes holding just the keys that changed. High-volume page_viewed is excluded from the wildcard forms and has to be named explicitly.Each request carries X-Usertour-Signature — a timestamp and an HMAC-SHA256 of the exact bytes on the wire, computed with the endpoint’s whsec_… secret — so a receiver can verify authenticity and reject replays. Rotating a secret takes effect immediately, including for retries already in flight. Send test event delivers a webhook.test message on demand, so you can prove the URL and your signature check work before any real event fires.On Usertour Cloud webhooks are included from the Starter plan; self-hosted instances are never gated. Managing endpoints requires the Owner role.🛡️ Delivery that survives an outage
A receiver that is down for an afternoon does not cost you messages. Each delivery gets 8 attempts spread over roughly 24 hours (5s, 1m, 10m, 1h, 4h, 8h, 12h), and a429 or 503 carrying Retry-After pushes the next attempt out to the time you asked for. Between attempts the job costs nothing — no held connection, no worker.An endpoint that keeps failing is slowed rather than dropped. After ten consecutive failed attempts it enters a cooldown (a minute, doubling toward an hour while failures continue, shown as a Cooling down badge). Messages created during a cooldown are still recorded and still delivered once the window passes — the pause defers traffic, it never discards it. A successful Send test event ends the cooldown immediately, so fixing your receiver and probing it is the fastest way back. If an endpoint fails continuously for seven days, Usertour disables it and emails the project owner; re-enabling resets the whole breaker.🧾 A message log you can re-send from
The endpoint’s detail page keeps every message for 30 days: the payload exactly as sent, its status, and each delivery attempt with response code, response excerpt, error and duration. Open a message to inspect the full JSON, or Re-send it — same payload, same message id, appended to the same attempt history — after fixing your receiver.Delivery is at-least-once and message ids are stable across retries and re-sends, so deduplicating on the id is safe. Ordering across messages is not guaranteed; use each object’s own timestamps.🔌 Endpoints as an API resource
Webhooks are also a v2 REST resource under the environment, so you can provision them from scripts or infrastructure-as-code, and the MCP server exposes them aslist_webhooks, create_webhook, update_webhook and delete_webhook for AI assistants. The signing secret is only returned to tokens holding webhook:manage — a read-only token gets every other field, because the secret is the ability to sign deliveries.🚀 A starting point after signup
A fresh signup now chooses how to begin instead of landing cold on the dashboard: Build with AI opens the connection guide for your assistant, Build it yourself continues straight in. The guide leads each client with a one-click entry — a VS Code install deeplink with the values pre-filled, Cursor’s deeplink, and direct paths into Claude’s and ChatGPT’s connector settings — and flips to a copyable starter prompt the moment your authorization lands. Manual steps remain as the fallback.🐛 Fixes
docker stopalways burned the full grace period and then killed the container. A shell held PID 1 with no signal handler, so the SIGTERM never reached Node. The image now runs Node as PID 1 and shuts down cleanly — HTTP server, Redis and Prisma closed in order.- Detail dialogs could grow past the screen and jump while closing. They are now bounded by the viewport and scroll inside, and the closing animation no longer collapses their content.
- Long values broke the webhook screens’ layout. Status badges and table headers no longer wrap inside their columns, long topics and URLs truncate with the full value on hover, and an over-long URL or an oversized topic list is caught inline in the form instead of coming back as a raw server error.
🛠️ Under the hood
The delivery pipeline is built on a shared outbound ledger — one row per message holding the payload as sent, one row per attempt — deliberately generic, because the integrations event push lands on the same bookkeeping next. The design and its failure-handling decisions are written up in ADR 0010.- Domain events are emitted after commit, so a rolled-back transaction can never produce a webhook. Delete events are sourced from
DELETE … RETURNING, which gives per-row attribution: concurrent duplicate deletes emit exactly one message instead of two with different ids. - Signing secrets are AES-256-GCM encrypted at rest, the treatment the SDK signing secrets and 2FA secrets already had. The plaintext boundary is the domain service; the delivery worker decrypts its own read.
- User-controlled URLs run behind the shared SSRF egress guard — HTTPS-only fail-fast at save time, DNS-rebinding-pinned resolution and IP-literal vetting at send time, and proxy environment variables are disabled on guarded deliveries, since dialing a proxy would leave the guard inspecting the wrong host.
ALLOW_PRIVATE_NETWORK_EGRESSopts self-hosted deployments out. - Retries live in Redis for a day, so an hourly reconcile sweep re-queues messages whose job was lost with the queue — resuming from the highest logged attempt, never from a row count.
- Concurrent writes to breaker and message state are compare-and-swap guarded end to end; the rules that came out of it are now a written convention (
docs/conventions/concurrent-state-writes.md) rather than tribal knowledge.
August 15, 2026
This release is about trust between your deployment and the proxies it runs behind. A zero-config self-host now reports correct URLs everywhere out of the box — the right scheme behind TLS-terminating edges like Railway and Cloudflare, the right port on the stock Docker setup — and the forwarded-header chain that feeds rate limiting and the audit log can no longer be spoofed. Startup failures now crash loudly instead of hanging silently.
🔗 Zero-config self-hosting tells the truth about its address
With noAPI_URL configured, the server derives its public URLs from each request — and every ingredient of that derivation now survives the trip through the bundled nginx. The edge proxy’s X-Forwarded-Proto is passed through instead of overwritten (allow-listed to http/https, tolerant of duplicate-header merges), the client’s Host header keeps its port, and the real peer is appended to X-Forwarded-For. Settings → MCP, the OAuth discovery metadata, the 401 challenge and the installation snippet all read the same derivation, so docker compose up shows a copyable, working MCP URL with zero configuration — on Railway, the whole OAuth connect flow works the same way.Derivation is only ever the fallback: every URL it produces can be pinned with API_URL / MCP_SERVER_URL, which win unconditionally.🛡️ A forwarded-header chain that can’t be spoofed
NewTRUST_PROXY setting (any form Express accepts — a hop count, an address list, false). The default trusts only the bundled nginx: every other peer counts as the client itself, so a spoofed X-Forwarded-For prefix can no longer mint fresh per-IP rate-limit buckets — on public hosts, LANs and Docker networks alike. The resolved client IP also becomes the actor IP in the audit log; if a real proxy fronts your container (Railway, Cloudflare, an ingress), declare it (e.g. TRUST_PROXY=2) so audit entries record operators rather than proxy addresses.🐛 Fixes
- Settings → MCP showed an empty Server URL on a zero-config self-host. The
/mcpendpoint itself self-described fine; the display read raw config. Both now share one derivation — along with the installation page’s API URL. - Behind a TLS-terminating edge, OAuth metadata advertised
http://and MCP clients refused to connect. nginx overwrote the edge’sX-Forwarded-Protowith its own scheme. It now passes the header through — including when duplicate headers arrive merged ashttps, https. - The port vanished from derived URLs. nginx’s
$hoststrips it, so the stock8011:80setup derived deadhttp://localhost/…links. The client’s ownHostheader is forwarded intact. - A bad
TRUST_PROXYvalue — or any startup error — left a zombie container. Bootstrap exceptions died in a log-only handler: the process neither listened nor exited, and restart policies never fired. Input is now sanitized ("", wrapping quotes andTrueall parse), anything still invalid throws naming the variable, and startup fails fast with exit code 1. globalConfigthrew over the legacy websocket transport whenAPI_URLwas unset (no HTTP request to derive from). It returns empty values instead.
🛠️ Under the hood
- The URL-derivation contract is pinned by e2e: scheme from
X-Forwarded-Proto, host and port fromHost, resolved per request, config always winning — and the derived MCP URL must equal theoauth-protected-resourcemetadata byte-for-byte (RFC 9728). - Rate-limit regression tests cover the spoofing paths: rotating fake
X-Forwarded-Forprefixes over one real client — public or private-range — land in a single bucket and hit 429. - The Docker-image workflow actions moved to their Node 24 majors, clearing the runner deprecation warnings.
- The README now opens with Build Your Onboarding with AI: three real prompts with their unedited results, and the self-host pointer for the Claude Code plugin (
USERTOUR_MCP_URL).
August 12, 2026
This release opens Usertour to programmatic and AI-assisted authoring: a public v2 REST API, an MCP server that lets assistants build and debug content directly, personal API keys and OAuth connections to reach them, and an audit log over everything they write. Content authored this way is held to a stricter bar than the builder’s — the API refuses configurations that would publish green and never render. Along the way the analytics response shapes were made honest per content type, versions gained author attribution and publish history, and project members can be restricted to specific environments.
🔌 Public v2 REST API
A contract-first API covering the whole product surface: content and versions (including full authoring of steps, blocks, conditions, actions and triggers), themes, attribute and event definitions, segments, environments, end users, companies, sessions, and analytics. Every response shape is generated from the same schemas the server validates against, published as an OpenAPI document at/api-v2-json and rendered in the docs site — so the reference cannot drift from the implementation.Reads support cursor pagination, orderBy, name search, typed filters (created-at ranges, published, completed) and expand for inlining related objects. Writes are transactional and return the stored result. Every endpoint declares its 400/401/403 shapes, errors carry stable machine codes (match on code, never on message), and responses include X-RateLimit-* pacing headers with a plan-tiered limit and a standard Retry-After on 429.🤖 MCP server for AI assistants
Point Claude Code, Cursor, Codex, VS Code or ChatGPT at/mcp and they can author, publish and debug content in place. The server ships 67 tools, a self-describing authoring guide (get_authoring_guide serves sections on demand), per-type write schemas (get_content_schema), and a routing map delivered in the initialize handshake so an assistant knows which tool answers which intent before its first call.Two diagnosis tools answer the questions support actually gets: diagnose_content runs the SAME runtime gates the SDK uses (“why isn’t my flow showing?” — published, identified, start rules, frequency, session state) and expands the condition tree with each leaf marked matched/unmatched/unknown, including the user’s actual attribute values; diagnose_user sorts everything published in an environment into showing / queued / blocked / browser-dependent for one person, with the slot races settled by the runtime’s own selectors.Settings → MCP carries per-client connection steps with your instance’s URL pre-filled.🔑 Personal API keys and Connected apps
Two ways in, both account-level and both bounded by your role. Personal API keys (utp_…) are created in Settings → Account with a purpose-named scope preset or a per-resource access-level picker, scoped to one project and the environments you name; they can be edited, rotated (new secret shown once) and deleted. Connected apps is the OAuth 2.1 + PKCE side — an assistant registers itself, you approve the scopes and environments on a consent screen, and no token is ever copied by hand.Both are checked live on every request: the token’s own scopes, intersected with the owner’s current role on that project, intersected with the environments it may act on. Demote or remove a member and their keys narrow or stop working immediately — no revocation step required. Env-targeted scopes (end-user data, sessions, segments, analytics, publish) must name their environments at creation; “all environments” is not grantable for them.🧾 Audit log
Every write through the open surface is recorded — API, MCP and the web admin alike — with the actor (user or token name), source, resource, environment and timestamp. Filterable by source, action, resource type, environment, actor and date range. Reads are never audited; captures always run, and the read page is gated on the Business plan (Growth sees a 7-day window).✋ Authoring that refuses to fail silently
Content built by an agent has no human watching a live preview, so the API enforces what the builder leaves to the eye. Node-local mistakes are rejected at write — a condition group with no children, a time window with no start, an event window with no unit, a step holding two questions, a placement shape that doesn’t fit the step kind. Version-level completeness is checked at publish, withvalidate_content_version as a dry run of the same rules.Warnings cover the legal-but-wrong: a launcher, banner or resource center with no start rules (published, reachable by nobody), a flow or checklist that nothing references and no rule starts, an embed whose provider never resolved, a {{ token }} naming a non-user attribute (it renders as an empty gap), a theme switch that drops the conditional variations the live version had, and a URL pattern of */ — which matches only the site root.📊 Analytics that says what it counts
Analytics responses are now shaped per content type instead of one generic envelope: flows report starts, completions and a per-step funnel with tooltip-target-missing counts; checklists add panel opens and per-task rows; launchers and banners report first-touch user counts (no totals — the events fire once per user); resource centers report opens and block clicks; trackers users and occurrences.unique* always counts distinct users in the range; what total* counts follows the type, and each field says which. Question analytics zero-fills every configured choice and echoes the rolling window it aggregated with.👥 Environment-scoped members, version history
Project memberships can be restricted to specific environments, and invites carry the same restriction. Versions now record who created them, and each content has a publish history — who put which version live in which environment, and who took it down — surviving later deletion of the version or actor.🐛 Fixes
waitMswas always seconds. The trigger and start-rule field is renamedwaitSeconds; the runtime never read it as milliseconds, so the old name mis-taught everyone who saw it.- A version that ever shipped is now frozen. Unpublishing used to unlock a live version for editing, silently rewriting what users had already seen; fork it instead.
- Banners had no start priority. Two banners competing for the single banner slot resolved arbitrarily; banner now takes a priority like resource center.
- Attribute conditions were not scoped by entity. A condition on a company attribute could be built against a user, and vice versa.
- Launcher fringe on dark hosts. A backdrop behind icon launchers leaked a light seam on dark pages; removed.
- Banner remount on SPA navigation, live-chat provider guard, choice-label ids and watcher idempotency in the SDK (0.7.9).
- Social login dropped the flow it interrupted. Signing in with Google or GitHub from a page that required a login (the MCP consent screen) landed on the homepage instead of returning; the target now rides the OAuth state round-trip, validated same-origin on the way back.
🛠️ Under the hood
- The v2 surface is a self-contained module: zod contracts generate both validation and the OpenAPI document, mappers are pure functions testable without DI, and a response-contract check runs on every e2e request so a mapper can’t quietly return a shape the spec doesn’t declare.
- Content authoring rides a codec pair — a decompiler that presents stored steps/rules as an intent-level representation, and a compiler that turns that representation back into internal structures (markdown→slate, blocks and rules→storage). A permanent round-trip corpus pins it against real content.
- Condition validators live in
@usertour/helpersas a single source of truth, shared by the builder and by the v2 publish/dry-run gate. - The MCP server is scope-gated by construction: a token only sees the tools its capabilities allow, and every handler re-authorizes on call.
- OAuth issues short-lived access tokens with rotating refresh tokens (60s grace so a lost response can’t brick a grant), and
MCP_SERVER_URLis the single truth for the advertised MCP resource — set it to serve/mcpon its own domain. - Test coverage: every MCP tool ships with an e2e case, the v2 capability matrix is asserted route-by-route against the OpenAPI document, and theme settings round-trip per field across 158 paths.
July 25, 2026
This release lets segments filter across entities: user segments can target company and membership attributes, and company segments can target the people inside them. Segment pages get a faster filter-editing flow to match, and trackers now follow company switches instead of staying frozen on their first evaluation. Under the hood, all segment conditions — runtime and list pages alike — now compile through a single filter builder, and the v2 WebSocket surface gains an end-to-end test harness.
🧬 Segments can filter across entities
User segments can now use company and membership attributes (“users whose company is on the Pro plan”), and company segments can use user attributes (“companies with at least one admin member”). Conditions that span entities bind to the same company relationship: an AND of cross-entity conditions only matches when one membership satisfies them together, not when two different companies each satisfy half. In segment lists a user matches if any of their companies qualifies; in live targeting the conditions evaluate against the company the user is currently active in — so what you preview in the list is what delivery enforces.🎛️ Faster segment filter editing
Segment list pages get a one-click Add filter flow, condition chips now carry an icon showing which entity the attribute belongs to (user, company, or membership), and the attribute pickers and create/edit dialogs show data-type icons in a more compact layout. The Save filter button also surfaces reliably — including after deleting the last remaining condition, which previously left the view and the saved segment silently out of sync.📡 Trackers follow company switches
The SDK used to keep a tracker’s condition snapshot frozen at first distribution: switch to a company that doesn’t qualify and the tracker kept firing, switch into one that does and it never started. Tracker sessions now re-distribute whenever a server-side condition result changes, so the client always evaluates the current rules. Ingestion got honest to match: a report only counts when it matches the exact snapshot the server distributed to that client, reports arriving after unpublish are rejected, and the Users card on tracker analytics now labels each row with the company where the events actually fired — “from B +2” when a user triggered the tracker in more than one company. SDK 0.7.8 carries the client half.🐛 Fixes
- Company segments with grouped conditions matched every company at runtime. Runtime evaluation didn’t recurse into condition groups, so a segment whose conditions were wrapped in a group silently compiled to “no filter” — auto-start rules fired (and hide rules stayed off) for companies far outside the segment, while the segment’s company list, which compiled correctly, looked right.
- Mixed-entity OR conditions were evaluated as AND at runtime. Company segment evaluation partitioned conditions into per-entity buckets that all had to match, dropping the OR semantics between them.
- Company lists dropped membership conditions. Offline company queries ignored membership-attribute conditions entirely; they now filter with the same semantics as runtime.
- Search no longer clobbers filters. On list pages, the search box’s OR clause could overwrite a condition filter’s own top-level OR; the two now combine as independent AND members.
🛠️ Under the hood
Segment evaluation was rebuilt on one compiler so list pages and live delivery can no longer disagree:common/attribute/filter.tsgains a three-projection builder —createBizUserConditionsFilter(with all / current / none company-scan modes) andcreateBizCompanyConditionsFilter— sharing a membership-anchored condition tree with boolean-simplified fallback branches for rows without memberships. Runtime segment evaluation, offline user/company queries, and list search all ride it.- Tracker ingest validates the report’s (content, version) pair against the socket’s distributed session and resolves the event id from that snapshot rather than the live Version row, with the publish-state check moved inside the ingest transaction. Batch diffs identify tracker sessions by composite key — they carry no biz session id — and compare tracker payloads so condition changes re-emit.
- The v2 WebSocket gateway gains an e2e harness with coverage batches across flow auto-start, checklists, launchers, banners, announcements, resource center, timers, frequency rules, session recovery, identity verification, and runtime segment evaluation.
July 16, 2026
This release lets you prove that
identify() and group() calls really come from your app, so your public environment token stops being enough to impersonate your users. Your backend signs a short JSON Web Token for each logged-in user; Usertour verifies it before accepting the identity. Verification is opt-in per environment and rolls out safely — unsigned traffic keeps working while a coverage meter shows you when it’s safe to switch enforcement on.🔐 Prove user identities with a signed token
Your environment token ships in every visitor’s page source — it has to, so the SDK can connect. That means the token alone can’t prove who is calling: anyone who has viewed your page could callidentify() with someone else’s user ID and read or overwrite that user’s data. Identity verification closes this. Your backend mints a JWT (HS256) signed with a per-environment signing secret only you hold, carrying the user’s ID as the sub claim (and optionally the company as companyId). Pass it to the SDK — usertour.identify(userId, attrs, { token }) — and Usertour rejects any identity claim that isn’t backed by a valid token. One token proves both the user and their company membership. See the Identity Verification guide for backend signing samples in Node, Python, Ruby, PHP and Go.🎛️ A new Identity Verification settings page
Settings → Identity Verification manages the whole lifecycle: generate a signing secret, copy it into your backend, and rotate it with zero downtime — a new secret and the old one are both accepted until you revoke the old one, and a Last used timestamp tells you when it’s safe to revoke. A built-in Validate a token tool takes a token you’ve generated and tells you exactly whether it verifies, and if not, why — expired, wrong signature, wrong algorithm, missing claim — so you debug the real problem instead of guessing.📊 Coverage-first, opt-in enforcement
Turning on enforcement while some of your pages still send unsigned identities would lock those users out, so the release is built to prevent that. Even with enforcement off, Usertour verifies every identity claim and records the result. The Signed traffic card shows the share ofidentify() and group() calls over the last 7 days that carried a valid token; when it reads 100%, you flip Require identity verification on with confidence. Anonymous visitors (identifyAnonymous()) stay exempt — they involve no backend, so they can’t be signed — and keep working with enforcement on.🔑 What identity verification does and doesn’t cover
With enforcement on, the environment token is no longer a write credential: nobody can impersonate your users, mass-create fake users, or attach users to companies they don’t belong to. It deliberately doesn’t try to hide published content (that’s served to anonymous visitors by design) or stop a real signed-in user from editing their own attributes. Signing secrets are stored encrypted at rest and are only ever visible to workspace owners, alongside your API keys.🛠️ Under the hood
Identity verification is a single verification path feeding an opt-in gate, designed across ADR 0008 (the verification model) and ADR 0009 (the JWT token format, which replaced an initial bare-HMAC design before release):- One
checkTokenclassifier is the single source of truth for both the runtime handshake and the console validator, so they can never disagree. Claim comparison is string-coerced (numeric IDs),exp/nbfcarry a 30-second clock tolerance, tokens are length-bounded before any decode on the unauthenticated handshake path, and a handshake with a company claim verifies its token exactly once. - Signing secrets live in a dedicated
EnvironmentSigningSecrettable —utv_-prefixed, AES-256-GCM encrypted at rest (HMAC needs the original value, so hashing is impossible by construction), at most two active per environment for the rotation window, withlastUsedAtobservability. The lifecycle mutations run in serializable transactions that retry on serialization failure. - The SDK keeps a single identity token as the source of truth for reconnect credentials: a refreshed token revives a handshake that was rejected while a token was expiring, and a server-rejected
group()claim rolls back with concurrency-safe compare-and-restore so it can never poison a later reconnect. - Verification runs only on the v2 WebSocket surface; the REST API already authenticates with secret, server-side keys, and the v1 socket path is out of scope as it winds down.
July 14, 2026
This release makes localization a first-class capability: translate flows, checklists, launchers, banners, announcements and resource centers into any number of languages, let AI fill in the blanks, and deliver each user the language matching their
locale_code attribute — with untranslated text always falling back to the source, so a half-finished translation never breaks content. Translations are versioned with the content and ship through the normal publish flow.🌍 A translation editor for every content type
Define your languages once under Settings → Localization, then every content gets a Localization tab: pick a language and translate side by side — source on the left, translation on the right, grouped the way the content is structured (per flow step, per checklist section, and so on). Rich text keeps its formatting automatically, red and yellow markers track what’s untranslated and what’s outdated, an Only untranslated filter hides finished work, and everything autosaves. Resource-center content lists get translatable display names, so entries no longer leak the internal content name to end users. See the Localization guide for the full tour.🤖 AI machine translation
One click on AI translate machine-translates everything that’s still untranslated — in batches, with live progress, resumable if a batch fails. Each row also gets a magic-wand button for translating just that text. Machine translation fills blanks only: it never overwrites a translation a human wrote or edited. On Usertour Cloud it ships with every paid plan; self-hosted instances bring their own provider — Anthropic, any OpenAI-compatible gateway (OpenAI, OpenRouter, Azure, local gateways), or AWS Bedrock, configured throughAI_* environment variables.For agencies and external translators, Export CSV / Import CSV round-trips the whole translation set — blank cells keep existing translations, stale rows are skipped safely.🚚 Delivery keyed on the user’s locale
Setlocale_code when you identify the user and Usertour picks the matching enabled translation — exact code first, then the primary language (fr-CA falls back to fr). The locale is never auto-detected from the browser: the right language for your product is the one your app renders in, and only your app knows that. Switching locale_code mid-session re-delivers live content in the new language on the spot. The widgets’ built-in interface text — checklist prompts, survey buttons, screen-reader labels, dates — follows the same signal and ships in ten languages.🔁 Versioned, publish-gated, preview-ready
Translations belong to the content version: enable a language when it’s ready, publish to ship it, and forks or restores carry every translation along. Editing translations of a live version quietly continues on a draft — merely viewing translations never creates one. A Preview button renders any language with your working translations applied, no publish needed, and source-copy edits flag the affected rows for review so translations never silently drift.🐛 Fixes
- Widgets now apply mid-session updates fully. A session update refreshed only part of the widget state, so changes like a plan’s branding flag — and now the interface language — didn’t take effect until reload.
- Checklists re-deliver when their delivered content changes. The change detector compared only task lists, so updates to the launcher button or checklist body were dropped as “no change”.
- Viewers see announcement content read-only instead of an editable-looking editor, and the announcement list’s guide link now points at the announcements guide.
- Settings row-action icons align on a shared icon box across all settings tables.
🛠️ Under the hood
Localization is built as a structural-merge pipeline with one delivery gate:- Translations live in
VersionOnLocalizationrows — a structural clone of the source per (version, locale), where empty string means untranslated. Delivery merges structurally: the source tree owns structure and behavior, the translation donates non-empty text. Rows are keyed by step cvid, so forks and restores copy them verbatim with zero remapping. - Locale resolution is a pure function of the
locale_codeuser attribute (seeded and backfilled as a default attribute), shared by content delivery, widget chrome, and the delivered-locale stamp on analytics events. - Machine translation is served by a new instance-level
aimodule (Vercel AI SDK; Anthropic / OpenAI-compatible / Bedrock with a three-tier AWS credential chain). Structured outputs are enforced end to end, and responses that split one translation across entries are dropped for retry instead of saving fragments. - The translation editor saves through read-write-symmetric payload builders: a session may only overwrite what it could read, so drifted or unreadable fragments of the stored row survive every save. Published versions fork lazily on the first actual write, and the editor pins its version so its own fork never remounts it mid-edit.
July 8, 2026
This release brings announcements back as a first-class content type: compose product news in the builder, target and schedule it, and it lands in the Resource Center as a dated feed — with unread badges on the launcher and, for the news that deserves attention, a one-time popup shown as a centered modal or a speech bubble. Alongside it, a round of content-editor autosave hardening so concurrent edits, republishes, and page exits no longer lose work.
📣 Announcements in the Resource Center
The new Announcement content type publishes into its own Resource Center tab as a feed: newest first, grouped under date separators, each entry showing its intro content. An optional Read more opens the full article on a detail page inside the panel, and navigating back returns to the feed exactly where you left it. Announcement content supports the full block editor, user-attribute interpolation, and buttons limited to the actions that make sense inside a feed.🔔 Notification levels: silent, badge, popup
Each announcement chooses how loudly it announces itself:- Silent — it simply appears in the feed.
- Badge — an unread count appears on the launcher, the announcement tab, and the Resource Center home row; opening the feed marks everything seen and clears the badges immediately.
- Popup — the newest unseen announcement additionally presents itself once, either as a centered modal or as a speech bubble anchored to the launcher, following all four launcher placements.
🎯 Targeting, scheduling, and analytics
Every announcement carries its own audience rules, evaluated server-side so the feed, the badge count, and the popup always agree on who sees what. Publishing stamps the announcement time; set a future time instead and the announcement stays hidden everywhere until that moment, then slots into the feed under the right date. Seen state is tracked per user and flows into analytics — announcement events show up in the user’s activity feed, and the new default events are backfilled into existing projects on upgrade so no analytics are lost.🐛 Fixes
- The content editor no longer loses edits under concurrent saves. Editing a published version’s content and settings at the same time could fork two drafts and drop one side’s changes, republishing could ship a stale config, and targeting edits could silently revert to the pre-edit rules after republish. Forks are now serialized and each save path applies its own changes.
- The save indicator tells the truth — it stays on while a debounced save is still pending and survives edits made mid-write, so leaving the page no longer discards work that looked saved.
- Previews no longer crash on empty versions — the launcher list and content-detail previews guard against versions that have no data yet.
- Resource Center home header background defaults to none, so themes without an explicit header color render as designed.
- SDK: a failed Resource Center data load is no longer mistaken for an empty one — failures are now retried, and widget socket round-trips are bounded by a timeout so a dropped acknowledgment can’t hang the panel.
🛠️ Under the hood
The announcement pipeline is built around a single service so its security-sensitive decisions can’t drift:AnnouncementServiceowns one visibility gate — published, not deleted, announcement time reached, targeting passed — shared by the feed scan and the direct by-id fetch, so a deep link can never expose what the feed would hide. Websocket payloads for the announcement APIs are validated at the boundary.- Read state lives in a dedicated
BizAnnouncementSeentable; marking is batched, and the insert-returning first-seen path emits each analytics event exactly once. - Default events/attributes backfill for existing projects moved from app boot into
prisma/seed, running idempotently on every deploy; the seed now connects overDATABASE_DIRECT_URLso a saturated connection pooler can’t silently skip it. @usertour/typesnow holds only types and enums — all instantiated constants moved to@usertour/constants.
June 24, 2026
This release brings enterprise single sign-on: any BUSINESS or ENTERPRISE project can now let its team sign in through its own OIDC identity provider — Okta, Microsoft Entra, Auth0, OneLogin, Authentik, or Google Workspace — configured entirely from Settings, with an optional force-SSO mode that routes everyone through the IdP. Alongside it, a new Installation page helps you wire up usertour.js and confirms it’s working, plus a round of stability and crash-reporting improvements.
🔐 Project-level single sign-on (OIDC)
Each project can connect its own OIDC identity provider from Settings → SSO — paste in the issuer, client ID, and secret, copy the generated callback URL into your IdP, and pick the default role for new members.- Works with any standard OIDC provider — Okta, Microsoft Entra, Auth0, OneLogin, Authentik, Google Workspace — through issuer discovery, with optional explicit endpoint overrides.
- Force SSO: require everyone in a project to sign in through the IdP; password and social logins are routed to the SSO entry instead.
- Invite-first provisioning by default: a user must hold a pending invite to join through SSO, with optional auto-provisioning (just-in-time account creation) when you’d rather let the IdP vouch for new members. An existing account is never silently linked into a project it isn’t already a member of.
- Branded sign-in: upload a project logo in Settings and it appears on the SSO sign-in page.
- Graceful edges: pending invites can be accepted through SSO, callback failures show friendly messages, and members aren’t locked out if the project’s SSO entitlement lapses.
🧩 SDK installation page
A new Settings → Installation page gives you the install snippet for the selected environment and a live check that polls until it detects your first identified user, so you know the SDK is wired up before you start building.🛡️ Stability & error reporting
- Automatic recovery from stale assets after a deploy — a missing code chunk now triggers a clean reload instead of a broken screen.
- Browser auto-translation no longer crashes the app, and unexpected errors are now reported to PostHog so they surface earlier.
🐛 Fixes
- Bulk selection in the users / companies tables tracks the right rows — after deleting a couple of entries the selection no longer jumps to unrelated rows (it’s now keyed by entity ID rather than row position).
- Double-escaped text in translated strings is fixed — apostrophes and quotes no longer render as HTML entities.
- Builder polish: the exit-button icon points outward, and the bubble notch matches the bubble’s background color.
- Dark mode and additional languages are still being completed — this release defaults the workspace to light / English until that coverage lands.
June 18, 2026
This release rebuilds the content builder and gives the whole admin app a visual refresh. Every content type — flows, launchers, checklists, banners, and resource centers — now lives in one route-driven builder that shares the same floating-panel chrome, settings fields, and navigation. Alongside it the workspace gains a full dark mode and a round of design-system cleanup so weights, cards, and controls read consistently everywhere.
🏗️ A rebuilt content builder
All five content types now share one builder shell — a floating sidebar panel, route-driven views (no more mode flags), and a common set of field primitives. Editing feels the same whether you’re in a flow, launcher, checklist, banner, or resource center. The sidebar footer was redesigned too: an honest save-state indicator (saving / saved / unsaved / failed) and a clear Exit, instead of the old misleading “Save preferences”.🌙 Dark mode
The entire back office now has a proper dark theme — a calm blue-tinted near-black with layered surfaces for panels, cards, popovers, and controls, so depth still reads without harsh borders.🎨 Design-system polish
A consistency pass across the app: emphasis weight unified from semibold to medium, a single type scale, calmer cards and checkboxes, borderless shadow-based dropdowns and popovers, and friendlier auto-generated step names. Builder tooltips were also rewritten in plainer, more conversational language.🐛 Fixes
- Launcher tooltips now render inside an iframe like flows and checklists, so the host page’s CSS can no longer corrupt their styling.
- Embedding a YouTube URL works in resource centers, banners, checklists, and launchers — they now resolve oembed instead of trying to iframe the raw watch URL.
- Modal placements apply both offsets on edge-centered positions, and icon-launcher theme opacity takes effect in the SDK.
- A step’s “Same as flow” theme persists instead of silently reverting to a specific theme.
- The element-picker button now shows a real tooltip so its purpose is clear.
May 30, 2026
This release rebuilds how the admin app fetches and caches server data — moving apps/web off its React-Context data layer onto Apollo’s normalized cache and a set of focused hooks, and unifying every list’s pagination behind shared primitives. For day-to-day use the wins are freshness and stability: a teammate’s edits show up without a manual refresh, the content detail page and theme builder no longer flash or go stale after a save, and the users / companies / sessions tables share one consistent, race-free pager. Under the hood this retires roughly twenty server-state Contexts and consolidates the duplicated users/companies UI.
What’s Changed
🔄 Live, consistent data across the workspace
The admin app now reads through a normalized Apollo cache, so a mutation in one place updates every view showing the same record. Shared lists run cache-and-network, which means members of the same project see each other’s edits without reloading. Account email / profile changes, theme edits, and content updates now reflect immediately and in full.✨ Content detail and theme builder stop flashing
Restoring a version or a publish-then-edit no longer blanks the content detail page mid-transition, and the theme builder reflects a theme edit the moment it saves instead of briefly showing the pre-edit state. The builder also stays mounted through background refetches rather than momentarily going blank.📜 Unified, sturdier list pagination
Users, companies, sessions, and analytics tables now share one cursor-pagination engine. Clicking the pager faster than the network can respond no longer races into showing one page while the indicator reads the next, and bulk-deleting the rows on the last page no longer strands you on a page that no longer exists. The contents list moved to infinite scroll, and the “Load more” lists no longer collapse or double-fetch.🚧 Explicit not-found pages
Opening a stale or invalid detail URL now shows a clear “not found” page instead of a silent blank screen.🐛 Fixes
- Mutations that reuse a cached record no longer fail. Outgoing variables now strip the cache’s
__typenametag, which the server’s input types had been rejecting. - The segment-filter “Save” button hides correctly after saving, and no-op edits stop re-running queries. Condition comparison now matches the server’s JSON-equivalent form instead of in-memory key differences.
🛠️ Under the hood
The apps/web data layer was reorganised end-to-end; the sections above are the visible surface, here is the shape behind them:- Apollo
InMemoryCacheswitched to normalized mode with per-mutationupdate(cache)callbacks. The app-wide default isno-cache, with an explicitSHARED_CACHE_QUERY_OPTIONS(cache-and-network) opt-in for shared lists and facade hooks. Recorded as ADR 0005 / 0006. - The server-state-in-Context anti-pattern is retired: ~20 Context providers deleted in favour of focused hooks,
AppContextis now a thin facade over identity hooks, and the remaining rawuseQuery/useMutationcalls are wrapped in@usertour/hooks. - List pagination unified into three documented primitives —
useCursorPagination(page buttons),useLoadMoreAccumulator(load-more), and a shareduseCursorFetchMorerequest-side helper — with cache-level merge owned by typePolicy accumulators. - Users and companies UI deduplicated into shared
components/segments/entity/*components. apollo3-cache-persistdropped (in-memory cache only); the analytics session table’s row-shared queries hoisted from per-row to per-table (~80 → 3 observers); a lighteruseShouldShowMadeWithreader replaces a three-queryuseSubscriptionon theme previews.
May 28, 2026
Hardens the segments / users / companies surface and lands a workspace-wide UI package consolidation.✨ Highlights
- Viewer-role members get a clean read-only list. Row checkboxes and bulk-action buttons (add to manual segment, remove, delete) no longer appear on the users / companies tables for viewer-role accounts — no more grey buttons or fake affordances they can’t act on.
- Segment dialogs settle down. The rename dialog no longer refetches the segment list on Cancel / Escape (used to flash the table); selecting a row on a MANUAL segment on the companies page no longer hits the error boundary; double-submit and view-only guards aligned across the user / company filter-save dialogs.
- Soft-deleted content surfaces correctly. The content detail page renders a “404 / This content has been deleted” placeholder instead of going blank for deleted flows / checklists / etc. Sessions tied to deleted content now carry a “Deleted” badge on the user-detail Sessions tab and the session-detail page so historical analytics stay legible.
- @usertour/ui consolidation (ADR 0004). The 38 per-component packages under packages/components/<atom>/ (button, dialog, popover, …) collapse into a single @usertour/ui aligned with shadcn’s single-directory convention. @usertour/frame is kept standalone so the embedded SDK / widget bundles stay unaffected — SDK usertour.js is byte-identical to the v0.8.2 build. See docs/adr/0004 for the full migration story.
- Non-English users no longer see hardcoded English literals from shared UI primitives — date-range picker presets, locale selector placeholders, and content-loading spinner messages now flow through i18n.
- Two DateRangePicker instances on the content analytics page (header + tooltip-target-missing dialog) no longer share a duplicate DOM id=“date” — axe / lighthouse no longer flag the a11y violation.
- SegmentEditDialog’s Save with a missing segment id now surfaces an error toast instead of silently dead-buttoning.
- Bulk delete / remove toasts no longer render “0 users deleted” on a successful action when the server’s count field comes back nullable — falls back to the confirmed selection count.
- useSaveCompanySegmentFilter no longer misattributes a post-save refetch failure as “save failed”.
- ENCRYPTION_KEY is wired through config and the in-service ‘development-key-not-secure’ fallback is removed — operators must set the env var explicitly (production deployments unaffected).
May 27, 2026
Wraps up the Settings module hygiene pass, codifies two project-wide React component conventions, and lands a batch of audit-discovered fixes.✨ Highlights
- Settings refactor wraps up. Shared primitives (NewItemButton, DestructiveConfirmDialog managed mode, useSettingsForm), consistent “New X” copy across create dialogs, and faster /settings/events (no per-row listAttributeOnEvents fetch on mount).
- Component-style conventions. Two new rules under docs/conventions/react-components.md — Radix-style named *Props + body destructure, and const over function for component declarations — applied across @usertour/ui, theme builder, and settings.
- Attribute & event picker overhaul. + New attribute defaults Object type to the active tab; the event dialogs’ attribute picker now scrolls inside the dialog, searches by display name (not UUID), and hides already-selected items.
- Profile name save no longer wipes the user’s avatar.
- Attribute delete handles the AttributeOnEvent foreign key instead of throwing a Prisma error.
- Salesforce-sandbox now writes to its own integration row (was silently aliasing production).
- Toggling an integration’s “Stream events” switch off no longer commits unsaved API key edits.
- Destructive dialogs stay locked while their mutation is in flight; 2FA Setup / Regenerate dialogs reset on Cancel; session delete / end dialogs no longer hang on soft-failure.
- Segment edit dialogs and theme variation rename no longer clobber the user’s typing on Apollo cache updates.
- PostHog “Personal API key” and HubSpot “Private App access token” labels restored (had regressed to generic “API Key”).
May 24, 2026
This release introduces a capability-based permission system that runs from a single role-capability matrix on the server through to the navigation gating in the web app. User-visible deltas are mostly about feedback — error toasts now show the server’s
actual reason, save dialogs confirm success and keep your input on failure, and a handful of admin pages no longer crash when you open a soft-deleted record. Under the hood, 11 per-module permission guards collapse into one capability guard backed by a
93-endpoint authorization test matrix.
What’s Changed
🛡️ Settings nav and routes match real permissions
VIEWER members previously saw greyed-out menu items and pages the backend would reject anyway (Themes, Integrations, API Keys, Team management). The web app now hides or redirects based on the user’s actual capabilities, derived from a single server-defined role-capability matrix. A VIEWER lands on a cleaner Settings shell with only what they can use.💬 Error toasts show the server’s actual reason
A handful of admin actions (End / Delete session, several integration and API-key flows) used to swallow the server’s error and show a static “Failed to X” string. They now surface the real reason — “You do not have permission to access this project”, “A resource with this identifier already exists”, and so on. The destructive toast shape is also unified across the app, dropping the odd “Error” headers that appeared in five places and nowhere else.✅ Save dialogs confirm success and retain input on failure
Create / Update Attribute and Event dialogs used to close silently on success (no green toast) and also close on failure (eating the user’s input). They now show a “successfully updated” toast on success, stay open on failure so you can retry without retyping, and the submit button no longer gets stuck loading on the failure path.🐛 Fixes
- Viewing a soft-deleted content no longer 500s.
getContentreturned a generic 500 when the underlying row was soft-deleted; it now returns a typed not-found response the UI can render. - Viewing a soft-deleted session no longer 500s. Same shape for
querySessionDetail. - Duplicate event / attribute names return a typed error. Creating a row with a duplicate
codeNamepreviously leaked Prisma’sP2002as a generic 500; it now returnsE0048(ResourceAlreadyExistsError). - Deleting a localization with localized version data no longer 500s. The delete now cascades
VersionOnLocalizationrows in one transaction. - Step / version-localization mutations stop bouncing on soft-deleted content.
contentVersionIsEditablenull-guards before readingeditedVersionId.
🛠️ Under the hood
The server’s permission surface was refactored end-to-end. The bullets above are the visible deltas; here’s the architectural shape behind them:- A single
ROLE_CAPABILITIESmatrix in@usertour/constantsis the source of truth for “what each role can do”. The previous pattern of 93@Roles([VIEWER, ADMIN, OWNER])decorators scattered across resolvers is replaced by@RequirePermission(Capability.X)plus onePermissionGuardthat reads the matrix. Adding a new role is now one matrix row, not 93 decorator edits. - 11 per-module guards (content / environments / biz / integration / localizations / attributes / themes / events / analytics / team / projects) collapse into one
PermissionGuardwith a scope-resolver registry. Each resolver registers how to deriveprojectIdfrom its args (e.g.contentId → content.projectId), so the guard’s “is this user a member of the resource’s owning project” check is one code path. UserOnProject.capabilities: [String]is now part of the GraphQL schema. The web app’suseAppContext().can(capability)reads from this array instead of re-deriving from the role string, so frontend and backend gates can’t drift.- A 93-endpoint × 5-role authorization test matrix lives in
test/e2e/endpoints.tsand drives two surfaces:permission.e2e-spec.ts(jest, 312 deny-direction assertions + cross-project IDOR coverage) andtest/smoke/spot-check.sh(env-driven bash tool, 558-row diagnostic against a seeded fixture). Adding a new role-gated endpoint is one row in the table; both surfaces pick it up. - Test factories adopt the outline-style “recursive override” shape —
buildX(prisma, overrides?)auto-builds missing parent FKs, so a new spec can doawait buildStep(prisma)and get a fully-wired project → environment → content → version → step chain in one line.
May 21, 2026
A bug-fix release: flows no longer re-trigger after dismissal, the user / company / session detail pages show live SDK data, and the session-management surface is cleaned up.🐛 Fixes🔁 Flows and checklists honour “show once”A single-step flow no longer re-starts every time it’s dismissed. “Once” is now the real default, and already-published content is healed automatically — no re-publish needed.🔄 Detail pages show live dataAttributes and events the SDK creates at runtime (identify / track) now appear on the User, Company, and Session pages without a hard reload.🧹 Smaller fixes
- 📋 Banner button “Start a flow / checklist” was empty — it now lists your content.
- 📊 Analytics charts went stale after deleting or ending a session — they now refresh along with the table.
- ⏳ Detail pages no longer flash “not found” before the record loads; a session still loading its events shows a spinner instead.
- 💬 Cleaned up the delete-session dialog wording.
May 20, 2026
Authentication overhaul: sign-up, login, forgot-password, team invites and
logout were rebuilt and hardened end to end, plus self-serve project selection.
Highlights
- Reworked auth flows — invite rebuilt, reset-password folded into the login page, reset codes hardened, and the whole signup/login/invite path tightened.
- Self-serve project selection — a user with no active project now lands on a dedicated page to create or pick one, instead of a silent “Unnamed Project” bootstrap or a blank admin shell.
Security
- Login no longer runs invite/side effects before the password is verified, and invites are bound to the recipient’s email.
- Refresh tokens are single-use and deleted on rotation/logout (no more
unbounded
revokedrows); a daily job sweeps expired rows. - Logout ends only the current session; “log out everywhere” stays reserved for password change / 2FA enrollment.
- Reset-by-code consumption + password update are atomic; OAuth email is lowercased at the boundary; invite races and seat-count edges tightened.
Fixes
- Logout / account-switch hard-loads a clean login page — no
?nextflash and no leaking of the page you just left. - Cross-tab auth sync: login/logout/register in one tab reloads the others onto the shared session; stale-tab drift on email-link flows self-recovers.
- Auth pages gate on globalConfig (no OAuth-button flash) and read it from a single source (no duplicate queries / double loading).
- Invite email now renders the project name correctly.
Performance
- Project initialization batched into far fewer queries.
May 17, 2026
This release polishes the sign-in flow and the routing layer that supports it. Auth pages now respect the user’s locale, sign-in remembers the page the user was trying to reach before being bounced, and the app lands on an environment the user actually
has access to instead of the hardcoded
/env/1/flows. Under the hood the auth router is rebuilt around a single state machine and @usertour/hooks’s monolithic gql.ts gets its first domain split.What’s Changed
🌐 Localization on auth pages
Eight auth pages and their shared components — sign-in, sign-up, registration, invite, password reset, set-up admin, and the two 2FA screens — now go throughuseTranslation('ui') under a new auth.* namespace. Switching to zh-Hans flips every label,
button, placeholder, and validation message; English remains the default.🔗 Deep-link return after sign-in
When a not-yet-logged-in user opens a deep link (e.g. a/env/X/users URL shared from elsewhere), they’re bounced to sign-in with the original URL preserved as ?next=…. After signing in, they land back on the original page instead of the default
home. Same applies on the way through 2FA — ?next= is carried across the challenge step.🧭 Environment-aware landing
The “where do I go after sign-in?” decision is no longer a hardcoded env id baked into server config. The SPA now picks the landing env fromuseAppContext().environment → localStorage → primary env → first env in the user’s list, so a user who only
belongs to env 7 is no longer dumped on env 1.🪟 404 stays inside the admin shell
Hitting an unknown URL while logged in used to drop the user on a fullscreen blank 404 with no way back to the app. The 404 now renders inside the same shell, with the sidebar still visible, so navigation is one click away. Hitting an unknown URL while not logged in funnels through the same sign-in flow as any other protected page (preserving?next=); hitting it on a still-being-set-up self-host instance redirects to /auth/setup-admin like the rest of the routes.🎨 Sign-in visual refresh
The sign-in / sign-up / 2FA / password-reset cards now sit on a subtle grid-and-glow background keyed off the site’s brand HSL variable, replacing the flat indigo gradient. Adjusting the brand colour in the future will carry these pages along automatically.🐛 Fixes
- Admin nav highlight restored. Users / Companies / Settings were no longer highlighted in the sidebar after navigating into them. Fixed by switching active-state detection from the (now wrong) inner-route id to URL pattern matching.
- OAuth failure lands on the sign-in page, not the API. Split-origin deploys (API and SPA on different hosts) used to dump OAuth-failure traffic onto a non-existent
/auth/signinroute on the API host. The redirect now prefixes the SPA host correctly.
🛠️ Under the hood
Theapps/web auth surface was reorganised end-to-end. The bullets above are the visible deltas; here’s the architectural shape behind them:- The hand-rolled
CustomRouteif-chain inroutes/index.tsxis replaced by<AuthGuard mode='guest' | 'user' | 'setup'>, a single three-mode state machine that owns every auth-related redirect (setup-admin enforcement, sign-in bounce with?next=, 2FA enrolment).routes/config.tsbecomes a nestedRouteObjecttree withReact.lazyper-page chunks; page-area providers (Environment / Attribute / Subscription) mount once at the user-area parent and stop remounting on every navigation. - Seven hand-rolled
<Card>+<CardHeader>blocks across the auth pages collapse into one<AuthCard>shell that owns the title chrome, description, footer slot, and loading skeleton. The three legacy React contexts that shadowedreact-hook-form’s<Form>(SignInContext / RegistrationContext / InviteContext) are dropped — the same data flows throughuseFormContext()and props. - The
Auth.redirectUrlfield on the GraphQL schema and theLOGIN_REDIRECT_URLenv var that fed it are gone. The SPA now owns the post-login destination decision:?next=(validated as same-origin) wins, otherwiseLandingRedirectresolves the env. OAuth callbacks redirect to the SPA root through the same handler. packages/hooks/src/hooks/gql.tshad grown to 1306 lines; the auth domain (13 hooks + 3 new mutation wrappers) splits out to a newhooks/auth.tsmodule. The why, and the triggers that should pull more domains out in the future, are documented indocs/adr/0002-hook-domain-boundary.md. The package also gets a README with the decision tree for adding new hooks.
May 16, 2026
This release polishes the content-detail editor: clearer empty-state guidance when an auto-start rule isn’t yet usable, two end-to-end races around publish-then-edit and rapid-delete eliminated, and the tracker editor brought in line with the auto-start
rules card so they no longer behave differently from the user’s side.What’s Changed🎯 Auto-start rules editor
- “No conditions yet” empty state. When the auto-start toggle is on but no conditions are set, ContentDetailAutoStartRules now surfaces an inline “rule won’t activate until you add at least one condition” card. The runtime is never sent an enabled=true rule with an empty autoStartRules — coerceEnabledForPersist writes enabled=false on the wire even while the local toggle visually stays on, so the user can see what they’re configuring without producing an un-actionable server state.
- Sibling settings hide while incomplete. Wait / Frequency / “Only start if not complete” / Priority now render only when the rule is on AND has at least one condition. Off-state hides them along with the conditions list; empty-state hides them because their writes would never reach the wire anyway (and fully-controlled Priority would visibly snap back on click).
- No-op saves removed. Flipping the auto-start switch when the coerced enabled value already matches the server no longer queues a save. Previously, turning the toggle on or off with no conditions wrote (false, []) even though the server was already in that state — and on a published version, that no-op write silently forked a new draft.
- “Only start if not complete” responds instantly on Frequency change. Switching Frequency between Once / Multiple / Unlimited used to leave the IfCompleted control hidden for ~1s because its visibility gate read from the setting prop, which goes through the debounced save / mutation / refetch round-trip. A localSetting mirror in autostart-rules now reflects user edits synchronously; a lastObservedSettingRef with deep-equal compare ignores reference-only prop flips emitted during the save cycle (where setIsSaving(true) triggers a parent re-render carrying the still-stale version.config).
- No flicker on Frequency switch. Switching options no longer triggers a redundant setData re-render in ConditionFrequency that briefly flashed the picker’s children. The prop-sync useEffect uses value-equality bailout (isEqual(prev, next)) so the user’s own edit echoing back through defaultValue doesn’t cycle the children.
- Trigger conditions share the auto-start empty-state UX. Auto-start rules and tracker rules now behave identically when their conditions list is empty: both show “No conditions yet” with the same hint. The showEmptyState gate moved from showEnabledSwitch && enabled && … to effectiveEnabled && …, so switchless consumers (tracker passes showEnabledSwitch={false}) automatically participate.
- Same “don’t autosave empty” policy. Tracker’s handleAutoStartRulesDataChange now calls debouncedUpdateVersion.cancel() when conditions go empty, mirroring content-detail-settings.tsx. Without this, a rapid delete sequence in the tracker editor had the same lost-deletion race as the auto-start card.
- Flow publish requires at least one step. useContentPublishState now keeps the Publish button disabled when content.type === FLOW and version.steps is empty — an empty flow has nothing for the runtime to render. Mirrors the existing tracker-needs-event-and-conditions check.
- Rapid condition deletion no longer resurrects deleted conditions. A “delete A → delete B → delete C” burst previously left the debounce queue holding the second-to-last value (e.g. [C]), which fired ~500ms after the user emptied the list and re-synced the server back to that stale state on refetch. Both content-detail-settings.tsx and content-detail-tracker-editor.tsx now call debouncedUpdateVersion.cancel() when conditions become empty.
- Version history shows the new draft after publish-then-edit. Editing a just-published content silently forks a new draft version (because ensureEditableVersionId refuses to update a published version in place), but the Apollo listContentVersions cache wasn’t auto-refreshed — so the Version tab kept showing the pre-fork list until reload. processVersion / saveVersionData / saveVersionTheme / saveVersionScheduledAt now detect fork via editableVersionId !== version.id and add refetchVersionList to the post-mutation Promise.all.
- Frequency / Wait pickers sync on content or version switch. ConditionFrequency and ConditionWait previously only read defaultValue for the initial useState value; subsequent prop changes were ignored, so navigating between contents left both pickers stuck on the prior content’s values until the user re-interacted. Both now sync internal state via a prop-sync useEffect on defaultValue change.
May 15, 2026
This release adds two-factor authentication to Usertour. Every account can now opt into TOTP-based 2FA from Settings → Account, and
self-hosted system admins can require it for everyone on the instance. Password login also gets per-email lockout after repeated failures.What’s Changed🔐 Two-factor authentication
- New TOTP-based 2FA flow using authenticator apps (Google Authenticator, Authy, 1Password, etc.). Secrets are stored AES-256-GCM encrypted; each user gets 10 one-time recovery codes (bcrypt-hashed) at setup, shown once with a Download / Copy / “I have saved” confirmation gate.
- Sign-in adds an /auth/2fa step when 2FA is on. The page accepts both an authenticator code and — via “Use a recovery code instead” — a one-time recovery code. The same flow applies to email/password and the Google/GitHub OAuth callbacks.
- Step-up auth on sensitive operations: disabling 2FA and regenerating recovery codes both require a fresh authenticator (or recovery) code. Recovery code consumption is an atomic CAS so a stolen list cannot be replayed concurrently.
- Settings → Account exposes the full lifecycle: enable, regenerate, disable, plus a recovery-codes sub-row that reflects current state.
- New Require 2FA for all users toggle on System Admin → Authentication. When on, non-enrolled users are redirected to a forced /auth/2fa/setup page on their next request and cannot bypass it via API clients.
- Server-side enforcement runs in two layers: turning the policy on revokes refresh tokens of every non-enrolled user, and a TwoFactorEnrollmentGuard rejects non-bootstrap GraphQL operations from non-enrolled users while the policy is active.
- The toggle is gated by the instance license. If the license stops covering 2FA, the policy goes dormant (not silently off): existing enrolled users keep verifying, new non-enrolled users stop being forced. Admins cannot turn the policy on until they have enabled 2FA on their own account, to avoid self-lockout.
- SaaS deployments don’t surface the instance-wide toggle — per-user 2FA only.
- Email/password login now locks out a given email after 10 failed attempts in a rolling 10-minute window, backed by a Redis Lua-script INCR
- EXPIRE helper. Lockout is per-email, not per-IP, and clears automatically on success.
May 14, 2026
This release collapses the monorepo’s two package scopes into one. @usertour-packages/* is gone — every workspace package now lives under @usertour/*, matching the four packages that were already there. No user-visible behaviour change; this is a
one-shot mechanical refactor that closes a long-running source of decision friction and rename churn whenever a package’s runtime shape changed.What’s Changed📦 Unified package namespace
- All 60 packages previously under @usertour-packages/* move to @usertour/: every packages/components/ and packages/radix/* leaf, plus business-components / builder / contexts / editor / finder / gql / hooks / i18n / icons / dom / tailwind / widget / ui. Apps and the four pre-unified packages (types, helpers, constants, license) keep their existing names.
- Roughly 791 import sites, every workspace package.json’s name and dependencies entries, the root build:deps script, the four dev* filter lists, turbo.json’s dev.dependsOn, and the SDK Vite bundler’s manual-chunk regex all switch over in one mechanical pass.
- docs/architecture/packages.md updates: the Namespace section now reads “every workspace package lives under @usertour/*”, and the P1 → P2 migration checklist drops the namespace-rewrite step that’s no longer needed. The types-vs-constants boundary still applies.
- @usertour/sdk bumps to 0.7.0 to signal the namespace rename, since every transitive @usertour-packages/* import path that SDK consumers reached through has shifted.
May 13, 2026
This release lands a shared plan-features matrix that finally unifies how the server enforces plan gates and how the pricing page presents
them — Subscription.overridePlan lights up across the stack so per-customer CS grants take effect everywhere instead of just one layer. The
team-invite path also gains long-missing safety nets, and the pricing settings page aligns with usertour.io’s marketing copy.What’s Changed🧱 Plan features matrix (cross-cutting infrastructure)
- New PlanFeatures type in @usertour/types and a shared PLAN_FEATURES matrix in @usertour-packages/constants/billing that covers every per-tier value the product gates on today: removeBranding, sessionsLimit, teamMemberLimit, environmentLimit, dataRetentionYears, apiRateLimit, plus placeholders for upcoming auditLogs / ssoSaml / ssoOidc gates.
- resolvePlanFeatures(planType, overridePlan), parseOverridePlan, and isWithinLimit helpers live in @usertour/helpers and run on both server and web — the same merged feature set drives runtime enforcement and UI rendering.
- Subscription.overridePlan (the JSONB column that was wired up but unread) is now the canonical layer for per-customer grants. Override fields replace base fields via spread, so a CS-granted seat bump or a legacy benefit ({“removeBranding”: true} for grandfathered Starter projects) lands in resolution without any code change.
- @usertour-packages/constants was promoted from P1 (source-direct) to P2 (pre-built dist) since the NestJS server now requires it at runtime. The P1 → P2 migration steps are codified in docs/architecture/packages.md.
- PlanType enum gains ENTERPRISE so the self-hosted license path stops using bare strings; the two existing Record<PlanType, …> maps on the web pick up the new key.
- ProjectsService gains resolveProjectFeatures, checkEnvironmentLimit, and checkTeamMemberLimit. The cloud / self-hosted bypass, subscription lookup, override merge, count query, and error throw all live in one place and accept a Prisma TransactionClient.
- EnvironmentsService.create was previously gated only by the client — useEnvironmentLimit disabled the form button but any consumer that bypassed the form (stale UI, direct mutation call) could create past the cap. The mutation now delegates to projectsService.checkEnvironmentLimit inside its own transaction. New EnvironmentLimitError (E0030) with en/zh messages.
- TeamService.inviteTeamMember’s hand-rolled hobby/starter/growth if-chain is gone; the gate now goes through projectsService.checkTeamMemberLimit, which honours overridePlan the same way the client does. The roughly 230-line dup between projects.service and web-socket.service for cloud / self-hosted config resolution also collapses — web-socket.service.getConfig delegates to projects.
- The same email used to accumulate multiple Invite pending rows on the team settings page because there was no dedup before prisma.invite.create. Each row counted against the seat quota, letting a project artificially fill its allotment by inviting one address twice. Two new errors register pre-create: TeamMemberAlreadyInvitedError (E0031) and TeamMemberAlreadyInProjectError (E0032).
- When the SMTP layer rejected the recipient (e.g. 550 / EENVELOPE for a bad mailbox), the freshly created invite row stayed in the DB while the client got a generic 500 and the new dedup check blocked the user from retrying. The mutation now wraps sendInviteEmail in a try / catch — failure soft-deletes the invite (matching the cancel / accept lifecycle) and surfaces InvitationDeliveryFailedError (E0033).
- Comparison table aligns with usertour.io: Support & service section becomes Community / Email / Priority with priority gated to Business only, the ghost Concierge support row is gone, and the Growth card’s redundant Live chat support line drops back to Email support.
- Every drift-prone row (Sessions / Data Retention / Environments / API rate / Team members / No-branding) is now matrix-driven via a typed matrixRow helper instead of a hardcoded values: [false, true, true, true] literal.
- Per-customer override surfaces only on the user’s current plan card and current plan column. Other cards stay base so a CS-granted sessions bump on Growth doesn’t make the Starter or Business columns claim the same number. The comparison table stays apples-to-apples for upgrade decisions while honouring the user’s actual entitlement on their own row.
- Pricing page icons switch from lucide-react + a custom BoxIcon to the project’s standard remix icons (@usertour-packages/icons).
- Unlimited sessions render as 123 / Unlimited instead of 123 / Infinity; the percent / threshold caption hides when the cap is unbounded.
- SubscriptionContext now exposes effective features: PlanFeatures so consumers stop re-resolving themselves.
- apps/web/src/hooks/use-plan-limits.ts adds useEnvironmentLimit, useTeamMemberLimit, and useSessionsLimit. Each hook composes the subscription features, the relevant resource list, and the self-hosted bypass into a single { limit, current, canUseMore } shape. Consumers like EnvironmentCreateForm and MemberInviteDialog shrink to one line and now honour override on the client side too — the server check the hook mirrors used to be the only place that saw the override.
- docs/architecture/packages.md gets a @usertour/types vs @usertour-packages/constants split (contracts vs values, with the “delete-the-line, what breaks?” heuristic), a P1 / P2 package-shape section with the 6-step migration checklist that this branch’s constants promotion follows, and a note that runtime values should share as soon as a second real consumer exists — including code that copies the same business contract values without importing them.
May 12, 2026
This release brings the same schema-driven rewrite to Actions that v0.7.2 brought to Conditions, lands a long-overdue package-layering pass
that flattens shared/ and codifies the architecture in an RFC, and finally seeds and stabilizes the predefined-event list so Events settings
reads cleanly.What’s Changed🧱 Actions (schema-driven replacement for the hand-rolled list)
- Replaced the legacy ContentActions component with a schema-driven registry parallel to Conditions: each action type (Step goto / Flow dismiss / Flow start / Page navigate / JavaScript evaluate / Launcher / Banner / Checklist dismiss) is now a self-contained schema with its own Summary, Editor, validate and normalize.
- Save-time validation gates on every consumer with an explicit Save click — flow trigger, checklist item, launcher behavior, resource-center block, step builder — so incomplete chips can’t slip through.
- Outer red indicator on button / question elements now reflects the actual chip state inside, keeping the visible error consistent with what reaches storage.
- Restored the v1 mutex semantic: Dismiss flow + Start new flow can coexist again (encoded as two pairwise sets against Step goto instead of one transitive 3-set).
- Launcher behavior’s debounced auto-save now gates on the same validateActions check so partial chips never reach the DB, with the validator hoisted to LauncherBuilder so subpage switches (Target / Tooltip modes) don’t bypass it. The gate also respects actionType so a stale action list under SHOW_TOOLTIP no longer blocks tooltip saves.
- Dismiss-chip remove button rebuilt as a plain <button> matching the other chip variants (the <Button size=“compact-icon-sm”> previously pinned height to 24px and didn’t snap to the chip edge).
- Flattened packages/shared/* into packages/* and dropped the shared- prefix where it had stopped carrying meaning: shared-editor → editor, shared-hooks → hooks, shared-components → business-components; builder, contexts, finder, gql, i18n, assets move up to top level.
- Promoted generic UI primitives (SelectPopover, ColorPicker, DateTimePicker, LoadingContainer, LocateSelect, ScaledPreviewContainer, ErrorTooltip) from shared-components into @usertour-packages/ui. ColorPicker took a userId prop so it no longer drags useCurrentUserId() and the Apollo hook tree into L1 UI.
- Added a layering RFC at docs/architecture/packages.md that defines L0–L5, the dependency rules, the decision tree for new packages, and the known drift it accepts.
- Every predefined event (PAGE_VIEWED, FLOW_STARTED, FLOW_ENDED, FLOW_STEP_, LAUNCHER_, BANNER_, RESOURCE_CENTER_, CHECKLIST_*, QUESTION_ANSWERED, TOOLTIP_TARGET_MISSING) now ships with a one-line description written in agent-agnostic passive voice — meaning it stays accurate whether the event is fired by the SDK, by user code via usertour.track(), or by the server-side API. Events settings page surfaces them directly under each event name.
- Switched the project-init event seed from a one-shot createMany to a per-event upsert, mirroring the existing attribute init, so re-running init backfills displayName + description on existing predefined rows.
- Events list query gained a secondary id order key so the predefined block stops reshuffling on every page refresh (a quirk left over from the original createMany clustering every row at the same createdAt).
- Tailwind content globs in apps/web and apps/sdk collapsed to packages//src/ so package relocations don’t silently drop styles again.
- Actions chip’s isEqual now routes through @usertour/helpers’ existing re-export instead of pulling fast-deep-equal as an undeclared transitive.
- SDK bumped to 0.6.9.
May 11, 2026
This release lands Theme Builder v2 — a full three-column rebuild with variations, schema-driven settings, and macOS-style browser-chrome previews — and replaces the legacy Rules engine with a schema-driven Conditions system that powers every condition
surface in the admin and runtime.It also brings multi-environment safety to publish/edit flows, sliding-indicator animation to tab navigation, and a broad UI consistency pass across settings tables, list headers, and content detail chrome.What’s Changed🎨 Theme Builder v2
- Rebuilt the theme builder as a three-column shell with variations on the left, schema-driven settings in the right inspector, and a live macOS-chrome preview in the middle.
- Added variations support with drag-to-reorder, inline rename, conditions, and Base pinning.
- Replaced the legacy v1 inspector with schema-driven field components (typed FieldDef schema, cascade rules, coverage-tested round-trip).
- Restored per-setting tooltips on every inspector field with full i18n in en-US and zh-Hans.
- Added preview-frame chrome (browser bar + widget switcher) so widgets keep their intended layout context.
- Added unsaved-changes warning on close and pre-publish readonly + validation guards.
- Theme detail now lives inside the shared sidebar shell, matching the rest of the admin’s detail-page architecture.
- New variations seed from the current Base settings rather than factory defaults.
- Polished active color swatch labels, font-color positioning, Auto fallback rendering, primary brand color seeding, banner alignment in chrome, and many smaller details.
- Replaced v1 Rules with a schema-driven Conditions system covering every condition type (user-attr, current-page, event, event-attribute, segment, content, element, text-input, text-fill, time, task-clicked, group).
- Migrated every Rules consumer in the admin and shared editor to the new component; deleted v1 Rules from shared-components.
- Added a Where-clause UI for event filters with errors that bubble up to the outer event chip and a [Where] badge that distinguishes it from [If].
- Added standalone exports for Frequency, IfCompleted, Wait, and Priority that reuse the same primitives.
- Validation now spans snapshot, property-based, and production-fixture tests.
- Date attributes now render in the chip as MMM d, yyyy, matching the picker trigger.
- Split absolute-date operator labels into separate dropdown vs chip forms so “Signed up on May 13, 2026” reads naturally.
- Inline the two range inputs for between on a single row.
- Keep “Add value” alive when list-attribute popovers reopen, and hide stale list values on valueless operators (is empty / has any value).
- Commit invalid edits on popover close so the save gate catches them; block save when checklist / resource center conditions are incomplete.
- Honor the consumer’s filterItems array order in the add-condition dropdown.
- The DateTimePicker calendar now sits above its parent popover (no more z-index clipping in builder chrome) and matches the conditions-popover trigger hover language.
- Say “Add filter” instead of “Add condition” when the same component is used to filter a list (segments, user/company tables).
- Replaced the instant data-state styling on Tabs with a shared motion.span that slides between triggers via framer-motion’s layoutId. Pill and underline variants both ride the same mechanism.
- Added a variant=“primary” cva variant for tinted active pills (replaces six call sites that hand-rolled data-[state=active]:bg-primary overrides).
- The content detail header’s underline now slides between Analytics / Content / Versions on route change.
- useContentBuilder and the rename / open-editor form now decide whether to fork using contentOnEnvironments (per-env source of truth) instead of the deprecated Content.publishedVersionId field. Resolves a class of bugs where a publish in one environment could send authors straight into a still-live version in another.
- Session status badges flip to terminal-first priority (Dismissed > Completed > Active) so a session that was completed and then dismissed reads as Dismissed, matching the underlying event log.
- “Finished in X” wording in session lists aligned to “Completed in X” to match the canonical FLOW_COMPLETED event name.
- Removed the “Published X ago” badge from the content detail header — per-env publish times don’t reduce to one number, and version history surfaces the detail when needed.
- Dropped the CreatedAt column from Attributes / Events / Environments settings tables. API keys keep it because creation time is a real security / rotation signal.
- Unified the eight “New X” buttons across settings and content lists onto a single RiAddLine + mr-2 h-4 w-4 pattern. Fixed New Events → New Event along the way.
- Segment headers now use horizontal dots (matching page-level overflow conventions elsewhere).
- Fixed analytics: NPS / Scale response-rate denominator now aligns with the rolling-window aggregate.
- Fixed resource center: active session resumes on refresh regardless of current page; attrCodes preloaded from action-block clickedActions.
- Fixed content loading spinner to center reliably inside detail pages’ min-h-full containers via a min-h-[60vh] floor.
- Extracted CompactPopoverTrigger to consolidate three identical inline trigger className chains (ConditionCombobox, ConditionSelect, DateTimePicker).
- Several i18n polishes across theme-builder option labels (“Dismiss the flow”, “Show a speech bubble instead”), DateTime operator phrasing, dead placement keys, etc.
- Build / typecheck: exported forwardRef prop interfaces so tsup —dts can emit valid declarations; aligned border.borderRadius type to number.
April 26, 2026
This release focuses on Resource Center session behavior, admin-ended session tracking, and a small documentation improvement in the web app.
🧭 Resource Center
- Updated Resource Center to follow one-session-per-user behavior, matching Banner behavior.
- Prevented dismissed or completed Resource Centers from being reactivated for the same user.
- Added a Resource Center guide link to the Resource Centers list header.
📊 Analytics & Session Management
- Added support for ending Banner and Resource Center sessions from the analytics/session management flow.
- Added admin-ended tracking for server-side ended sessions.
- Added end-reason attributes for Banner and Resource Center dismissed events.
- Reused dismissed-event session ending logic across Checklist, Launcher, Banner, and Resource Center.
⚙️ SDK & Types
- Added
ADMIN_ENDEDas a content end reason. - Added
banner_end_reasonandresource_center_end_reasonevent attributes. - Bumped
@usertour/sdkfrom0.6.4to0.6.6.
April 25, 2026
This release introduces Resource Center end-to-end, with major upgrades across content building, SDK delivery, theming, live chat, analytics, and user-facing navigation.It also improves user, company, and session management workflows, with redesigned detail pages, better activity feeds, stronger table customization, and clearer analytics.
🧭 Resource Center
- Added Resource Center as a new content type with full builder, preview, SDK, widget, and server support.
- Added tab-based Resource Center navigation with configurable tabs, icons, ordering, and filtered visibility.
- Added Resource Center blocks for rich text, action links, dividers, content lists, sub-pages, and live chat.
- Added search, detail views, and grouped display for Resource Center content lists.
- Added Resource Center launcher behavior, visibility controls, open/close lifecycle handling, and persisted navigation state.
- Added conditional block visibility and attribute preloading for more personalized Resource Center experiences.
- Added Resource Center theme settings for launcher styling, panel sizing, header background, logo upload, and layout controls.
💬 Live Chat & Actions
- Added live chat block support for multiple providers and custom JavaScript providers.
- Added SDK live chat management for loading, showing, hiding, and cleaning up provider widgets.
- Improved live chat behavior when Resource Center blocks are opened, closed, or destroyed.
- Added collapse-to-launcher behavior after Resource Center action and content-list item clicks.
📊 Analytics
- Added Resource Center analytics for views and clicks grouped by tab and block.
- Added Resource Center event attributes for tab ID and tab name.
- Added analytics support for announcement-style Resource Center content.
- Improved analytics charts, granularity controls, session columns, and status display.
- Improved analytics labels, tooltips, ranking indicators, and CSV export handling.
- Fixed Resource Center analytics ordering and array payload handling.
👥 Users, Companies & Sessions
- Redesigned user, company, and session detail pages with clearer layout and navigation.
- Added richer activity feeds with event categorization, icons, translations, and count labels.
- Improved session lists with clickable rows, status columns, and clearer session detail views.
- Improved company/member attribute display and membership layouts.
- Added
externalIdcolumns for user and company tables. - Added collapsible search and improved filter toolbar behavior for user and company tables.
- Improved column customization with drag-and-drop, search, visibility management, and divider rows.
🎨 UI & Builder Improvements
- Added a shared icon picker with upload, URL, and built-in icon options.
- Replaced initials avatars with a curated default avatar icon set.
- Improved Resource Center builder layout, block settings, validation, loading states, and error tooltips.
- Added Resource Center preview support in theme settings.
- Improved admin layouts, subpage layouts, warning display, and navigation clarity.
- Rewrote tooltips and labels across auto-start, hide rules, attributes, events, announcements, and Resource Center settings.
⚙️ SDK, Server & Infrastructure
- Added SDK Resource Center APIs and global launcher visibility handling.
- Added Resource Center socket handling, reconnect restoration, session activation, and
RESOURCE_CENTER_STARTEDsupport. - Improved server-side content orchestration, session building, condition evaluation, socket emitting, and event tracking.
- Added PostgreSQL backup script support and Dockerfile updates for backup tooling.
- Added Redis TLS support through environment configuration.
- Updated default initialization for missing attributes and clearer predefined attribute descriptions.
March 23, 2026
This release introduces Event Trackers end-to-end, with major upgrades across event creation, rule building, tracking context, and analytics workflows.It also adds a new Admin Panel for self-hosted deployments, including system admin setup, instance settings, license management, and user/project administration tools.
📡 Event Trackers
- Added Event Trackers for capturing and managing event-based tracking flows.
- Added event creation and tracker-related content management capabilities.
- Added event-based rule building with new event condition and filtering components.
- Expanded event tracking with richer client and business context.
- Improved websocket event tracking handling, validation, and response behavior.
🛠️ Self-Hosted Admin Panel
- Added a new Admin Panel for self-hosted deployments.
- Added a setup flow for system administrators.
- Added user and project management capabilities for self-hosted teams.
- Added instance settings management for general and authentication configuration.
- Added project-level subscription management controls.
🔐 Licensing & Instance Management
- Added license key upload, validation, and instance-level license management.
- Improved license limit handling and related error messages.
- Updated licensing-related validation behavior across instance and project flows.
📊 Analytics & Export
- Added CSV export support for analytics-related data.
- Improved analytics queries and filtering capabilities.
- Improved export handling for checklist, session reason, and flow question data.
- Added and expanded test coverage for export payload generation.
✨ UX & Platform Improvements
- Improved admin navigation, layouts, and search experience.
- Improved project member management and owner selection flows.
- Improved redirect URL handling through configuration.
- Updated terminology from
companytoprojectacross multiple areas. - Improved rule builder behavior, state handling, and styling.
- Fixed date-fns-tz type issues.
- Upgraded server dependencies to address related advisories.
March 4, 2026
This release introduces the new Banner content capability end-to-end, with major upgrades across SDK rendering, content configuration, URL-based targeting, and interaction handling.It also includes a set of reliability and UX refinements for preview, analytics, and session orchestration.
Full Changelog: https://github.com/usertour/usertour/compare/v0.4.9…v0.5.0
🧩 Banner Content Type (Core)
- Added full Banner content type support across editor, preview, and runtime flows.
- Implemented banner embed placement controls with more consistent rendering behavior.
- Improved banner layout structure for better alignment, readability, and responsive presentation.
- Added configurable banner wrapper behavior, including theme padding and max-width handling.
- Standardized z-index behavior for banner-related components to reduce overlap issues.
🎛️ Content Configuration & Interaction
- Added button visibility and disabled-state conditions for more flexible interaction logic.
- Added banner dismiss action handling for better user-controlled behavior.
- Improved content detail settings and priority logic for content-type-specific behavior.
- Refined descriptions and guidance text in content settings to improve usability.
🔗 URL Targeting & Link Handling
- Added module-level URL filtering and improved URL change detection.
- Added link editing capabilities and URL decorator support for richer link control.
- Improved targeting consistency for condition matching and runtime content delivery.
📊 Analytics & Session Reliability
- Extended analytics data model and queries with content field support.
- Improved session creation flow with
findOrCreateBizSessionplus distributed locking. - Reduced duplicate session creation risk in concurrent scenarios.
🖥️ Preview & UI Refinements
- Improved browser preview layout and visual structure.
- Enhanced badge placement and preview presentation for banner content.
- Cleaned up unused preview components and improved overall UI consistency.
- Updated default embed width behavior for better visual balance.
🧹 Refactors & Cleanup
- Consolidated parts of content create/detail flows for better maintainability.
- Removed legacy NPS/Survey references from content-related modules.
- Performed structural refactors in banner/theme settings for clearer logic and easier extension.
Full Changelog: https://github.com/usertour/usertour/compare/v0.4.9…v0.5.0
February 12, 2026
📦 v0.4.9
This release focuses on improving Popper stability and refining SDK styling calculations, particularly around button sizing and Tailwind font-weight integration.🧭 Popper Stability Improvements
- Improved reference element handling to prevent Popper flash during initialization and repositioning.
- Reduced visual flicker when tooltips or modals mount under dynamic layout changes.
- Enhanced positioning reliability in async rendering scenarios.
🎛 SDK Component Fixes
- Fixed incorrect button padding calculations in SDK components.
- Updated padding logic to derive height from
font-size, ensuring correct button height when used with utilities likeleading-none. - Improved sizing consistency across different Tailwind setups.
🎨 Tailwind Integration Fixes
- Corrected SDK font-weight variable mappings in Tailwind configuration.
- Aligned font-weight variable names with Tailwind conventions.
- Improved typography customization reliability when overriding theme tokens.
Full Changelog:
https://github.com/usertour/usertour/compare/v0.4.8…v0.4.9
January 29, 2026
🚀 v0.4.8 — Editor makeover, launcher icons & bug fixes
✨ What’s New
- Revamped editor toolbar with a sleek TipTap style
- Editor got a facelift: new color picker, smoother drag & drop, and more flexible layouts for easier editing
- Columns in the editor now support padding adjustments
Usertour.jsnow has a handydisableEvalJsmethod- Website UI is more responsive across devices
- Flow components now support Speech Bubbles
- Launcher icons can now be uploaded and we added more built-in options
🐛 Fixes
- Checklist preview now shows the header properly
- Star Rating no longer overflows when there are lots of stars
- Editor won’t reset button text when you select an action
- NPS can now set user properties even when selecting 0
January 9, 2026
🚀 v0.4.7 — UI polish, segment & checklist fixes, UX improvements
This release focuses on UI/UX refinements, segment & filter stability, checklist behavior fixes, and multiple builder and SDK improvements to make Usertour more polished and reliable.🧠 Segment & Filtering
- Set default column when initializing or creating a segment.
- Fixed a bug where column filters stopped working after selecting a filter in segments.
🎨 UI / UX Improvements
- Improved focus effect for Launcher checklist.
- Fixed preview flash issue by scaling preview components from
0 → 1. - Optimized progress bar styles.
- Fixed theme list preview overflow issues.
- Fixed default theme button height calculation errors.
- System theme now properly disables buttons.
- Improved layout for theme and trigger components that were too narrow.
- Fixed SDK button height issue caused by default Tailwind
line-height: 1.25rem.
✅ Checklist & Flow Fixes
- Fixed issue where completion animation reappeared after dismissing a checklist.
- Fixed inaccurate checklist list styles.
- Added title length limits to checklist task breakdown.
- Fixed browser history back behavior when switching between Draft and Published states.
- Improved launch rule auto-expand animation for smoother transitions.
🛠 Builder & Page Improvements
- Standardized page buttons to use
<Button>components so they can receive focus. - Removed globalSocketId to avoid stale or unnecessary socket identifiers.
- Session list now displays company information.
📊 Analytics & Data
- Improved handling of numeric stats with large arrays and overly long labels.
- Unified right alignment for step-level analytics data.