# Attributes
Source: https://docs.usertour.io/api-reference-v2/attributes
Custom attributes on users, companies, and events — naming and data types.
Users, companies, and events carry custom **attributes** that extend the default
data model. The schema of your attributes is exposed by the **attribute
definitions** endpoint (`attribute:read`); the values live on each user/company
object.
## Naming
A `codeName` created through the API must start with a letter and contain only
letters, digits, and underscores, 2–100 characters (`snake_case` is the
convention — `signed_up_at`, `last_login_time`). It is case-sensitive and unique
per scope. SDK ingestion is deliberately more lenient: an attribute key your app
sends that isn't defined yet is **auto-created** with a type inferred from the
first value — so send a number as `42` (not `"42"`) and dates as full ISO
timestamps; a wrongly inferred type can be corrected later while no stored value
conflicts.
Display names (shown in the Usertour UI) are configured separately — e.g.
`signed_up_at` can display as "Signed Up".
## Data types
| Type | Description |
| ---------- | --------------------------------- |
| `string` | A string. |
| `number` | Integer or floating point. |
| `boolean` | `true` or `false`. |
| `datetime` | A point in time, ISO 8601 in UTC. |
| `list` | A list of strings. |
## Best practices
* Use the most specific type — store dates as `datetime`, flags as `boolean`
(not strings).
* Keep names concise but descriptive; avoid system/reserved names.
* Don't store large blobs in attributes.
# Authentication
Source: https://docs.usertour.io/api-reference-v2/authentication
Personal API tokens, scopes, and project access.
The v2 API authenticates with **personal API tokens**. Create one in your
Usertour dashboard under **Settings → Personal API keys**, then send it as a
Bearer header on every request:
```
Authorization: Bearer utp_xxxxxxxxxxxxxxxxxxxxxxxxxxxx
```
A token is owned by a user and has three properties that gate every request:
* **Projects** — the token is bound to one or more projects. A request to
`/v2/projects/{projectId}/...` is rejected unless the token includes that project.
* **Scopes** — fine-grained capabilities. A request is rejected unless the token
carries the scope its endpoint requires.
* **Environments** — an optional allowlist. Environment-targeted operations
(publishing, end-user data, sessions, …) are rejected outside it (`E1029`);
with no allowlist the token covers every environment of its projects.
**The environment allowlist is not a content-isolation boundary.** It fences
*delivery and end-user data* — publishing / unpublishing, users, companies,
sessions, segment membership, analytics, and the environment records themselves
(an out-of-scope environment's SDK token is withheld). It does **not** limit
which content a token can see or change: content, versions and themes are
**project-level**, so any token carrying content scopes can read every piece and
every version in the project — including one that is live in an environment it
may not act on — and can edit or delete them with `content:update` /
`content:delete`.
So a "staging-only" token still reads (and can rewrite) what is live in
production. If you need someone genuinely walled off from another environment's
content — a contractor, an agency — put that work in a **separate project**.
Connections authorized over [MCP OAuth](/api-reference-v2/mcp) hold `uto_`
tokens with the same three properties (granted on the consent screen); they
authenticate REST requests exactly like a personal `utp_` token.
## Scopes
| Scope | Grants |
| -------------------- | --------------------------------------------------------- |
| `content:read` | Read content and versions |
| `content:create` | Create / duplicate content, create draft versions |
| `content:update` | Edit drafts (steps, rules, theme, body), restore versions |
| `content:delete` | Delete content |
| `content:publish` | Publish / unpublish a version to an environment |
| `theme:read` | List / get themes |
| `theme:create` | Create themes |
| `theme:update` | Update themes |
| `theme:delete` | Delete themes |
| `user:read` | Read end-users (and their event history) |
| `user:write` | Create or update end-users |
| `user:delete` | Delete end-users |
| `company:read` | Read companies (and their event history) |
| `company:write` | Create / update companies and manage memberships |
| `company:delete` | Delete companies |
| `session:read` | Read content sessions |
| `session:manage` | End or delete content sessions |
| `attribute:read` | Read attribute definitions |
| `attribute:create` | Create attribute definitions |
| `attribute:update` | Update attribute definitions |
| `attribute:delete` | Delete attribute definitions |
| `event:read` | Read event definitions |
| `event:create` | Create event definitions |
| `event:update` | Update event definitions |
| `event:delete` | Delete event definitions |
| `segment:read` | List / get segments |
| `segment:create` | Create segments |
| `segment:update` | Update segments and manage manual members |
| `segment:delete` | Delete segments |
| `analytics:read` | Read analytics data |
| `environment:read` | List / get environments |
| `environment:manage` | Create / rename / delete environments |
Grant a token only the scopes it needs.
## Errors
| Situation | Status | Code |
| ------------------------------- | ------ | ------- |
| Missing `Authorization` header | 401 | `E1010` |
| Unknown / invalid token | 403 | `E1000` |
| Token not scoped to the project | 403 | `E1011` |
| Token lacks the required scope | 403 | `E1012` |
See [Errors](/api-reference-v2/errors) for the full list.
## Using a token with MCP
The [MCP endpoint](/api-reference-v2/mcp) carries no project in its path, so a
token used with MCP must be scoped to **exactly one project**. Multi-project
tokens work for the REST API but are rejected by MCP tool calls.
# Blocks
Source: https://docs.usertour.io/api-reference-v2/blocks
The visual building units of content — text, image, button, embed, question, and columns — with their exact payloads.
**Blocks** are the visual units that make up content. They appear in a flow
step's `content[]`, and in the rich content of checklists and banners. (The
resource center has its own block vocabulary — see
[Type-specific data](/api-reference-v2/type-data#resource-center).) A block is a
small tagged object:
```json theme={null}
{ "object": "block", "id": "bk_…", "type": "text", "markdown": "Hello" }
```
* **`id`** is the field-merge write handle. Echo a block's `id` to update it in
place (styling and other unmodeled details are preserved); **omit** `id` to
create a new block. (`object: "block"` is returned on read; you don't send it.)
* **`type`** selects the block shape below.
Rich text is a small **markdown subset** — see
[Content representation](/api-reference-v2/content-representation#rich-text).
Conditions and actions referenced here are documented in
[Conditions & actions](/api-reference-v2/conditions-and-actions).
## text
A rich-text paragraph block.
| Field | Type | Required | Description |
| ---------- | -------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | string | | Field-merge write handle. |
| `type` | `"text"` | yes | |
| `markdown` | string | yes | A small markdown subset: paragraphs, `# `/`## ` headings (h1/h2 only — no h3+), `-`/`*` and `1.` lists, \`\`\` code fences; inline `**bold**`, `*italic*`, `[text](url)`, and `{{ attribute_code \| default: "x" }}` for user attributes. Anything outside this subset is SILENTLY normalized, not rejected: h3+ → h2; blockquotes flatten to paragraphs; tables, horizontal rules, strikethrough, inline images/code, and liquid filters other than `default` are dropped. Unsupported syntax won't round-trip — don't rely on it. |
```json theme={null}
{ "type": "text", "markdown": "Welcome **{{ first_name | default: \"there\" }}** 👋" }
```
## image
| Field | Type | Required | Description |
| -------- | --------------------------- | -------- | ------------------------- |
| `id` | string | | Field-merge write handle. |
| `type` | `"image"` | yes | |
| `url` | string | yes | Image URL (http/https). |
| `alt` | string | | Alt text. |
| `link` | `object{ url, newTab }` | | Wrap the image in a link. |
| `width` | [Dimension](#shared-shapes) | | Image width. |
| `margin` | [Spacing](#shared-shapes) | | Outer margin. |
```json theme={null}
{
"type": "image",
"url": "https://example.com/logo.png",
"alt": "Logo",
"width": { "unit": "percent", "value": 80 },
"link": { "url": "https://example.com", "newTab": true }
}
```
## button
| Field | Type | Required | Description |
| -------------- | ------------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | string | | Field-merge write handle. |
| `type` | `"button"` | yes | |
| `text` | string | yes | Button label. |
| `actions` | [Action](/api-reference-v2/conditions-and-actions#actions)\[] | | What clicking does (e.g. go to a step, dismiss). |
| `disabledWhen` | [Condition](/api-reference-v2/conditions-and-actions#conditions)\[] | | REACTIVE slot — polled live in the browser (the button disables the moment the conditions match). Client-evaluable condition types only: attribute / current\_url / element / text\_input / text\_filled / time\_window; event / segment / content\_state are rejected (E1017). |
| `hiddenWhen` | [Condition](/api-reference-v2/conditions-and-actions#conditions)\[] | | REACTIVE slot — polled live in the browser (the button shows/hides as conditions change). Same client-evaluable-only rule as `disabledWhen`. |
| `variant` | `primary` \| `secondary` | | Visual style. |
| `margin` | [Spacing](#shared-shapes) | | Outer margin. |
A button needs both `text` **and** at least one action to be publishable.
```json theme={null}
{
"type": "button",
"text": "Next",
"variant": "primary",
"actions": [{ "type": "goto_step", "step": "pricing" }]
}
```
## embed
An embedded URL (video, iframe-able page, …).
| Field | Type | Required | Description |
| -------- | --------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | string | | Field-merge write handle. |
| `type` | `"embed"` | yes | |
| `url` | string | yes | Paste the page's normal URL (e.g. a youtube.com/watch link) — on write it is resolved through the standard oEmbed provider registry (YouTube, Vimeo, Loom, Figma, …) and the provider's official embed markup is stored, so you never hand-build /embed/ URLs. A URL no provider claims is iframed as-is — it renders only if that site allows being framed (no X-Frame-Options/CSP block). |
| `width` | [Dimension](#shared-shapes) | | Width. |
| `height` | [Dimension](#shared-shapes) | | Embed height. Optional ONLY for provider embeds (YouTube/Vimeo/… size themselves by aspect ratio); a URL with NO oEmbed provider has no ratio, and omitting a pixel height leaves the iframe at the browser's built-in default — a strip \~150px tall, almost never the intended size (validate warns). For plain-iframe URLs always set `{ "unit": "pixels", "value": … }`. |
| `margin` | [Spacing](#shared-shapes) | | Outer margin. |
```json theme={null}
{ "type": "embed", "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", "width": { "unit": "percent", "value": 100 } }
```
## question
A survey question. The `question` field is one of the four [Question](#questions)
shapes below; `actions` run after the user answers.
| Field | Type | Required | Description |
| ---------- | ------------------------------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | string | | Field-merge write handle. |
| `type` | `"question"` | yes | |
| `question` | [Question](#questions) | yes | The question definition. |
| `actions` | [Action](/api-reference-v2/conditions-and-actions#actions)\[] | | Actions that fire when this question is answered (on pick for nps/rating/single-select; on its Submit button for text/multi-select). Put a `goto_step` HERE to advance to the next step — without it the question records the answer but the flow does NOT advance (validate flags the next step "not reachable"). Do NOT add a separate `button` block just to advance: it doubles up with the question's own submit affordance. |
```json theme={null}
{
"type": "question",
"question": { "kind": "rating", "name": "Satisfaction", "style": "star", "range": { "low": 1, "high": 5 } }
}
```
### Questions
Every question needs a `name` (used for analytics). `bindAttribute` (optional)
stores the answer on a user attribute. `cvid` is server-owned (returned on read).
#### `nps`
| Field | Type | Required | Description |
| --------------- | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `kind` | `"nps"` | yes | |
| `name` | string | yes | The question's internal name / analytics label (it is the `questionName` on captured responses). It is NOT rendered to the user — the widget shows only the input (scale / options / text field), not this string. To show a visible question prompt, add a `text` block in the SAME step before the question block; a question with only a `name` renders as bare options with no question text. |
| `cvid` | string | | |
| `lowLabel` | string | | |
| `highLabel` | string | | |
| `bindAttribute` | string | | Optional: codeName of an EXISTING attribute (create the attribute definition first) to ALSO save this answer onto the user for targeting/segmentation — use the codeName, NOT the id. The write does not check it, but the version validation WARNS when the attribute is missing or its dataType mismatches the answer — read warnings. A wrong code that slips through silently captures nothing at runtime. Match the attribute dataType to the answer: number (nps / rating), string (single-select choice), list (multi-select choice). Leaving it unset still records the answer as a response event — bind only when you need to target/segment on it. |
#### `rating`
| Field | Type | Required | Description |
| --------------- | --------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `kind` | `"rating"` | yes | |
| `name` | string | yes | The question's internal name / analytics label (it is the `questionName` on captured responses). It is NOT rendered to the user — the widget shows only the input (scale / options / text field), not this string. To show a visible question prompt, add a `text` block in the SAME step before the question block; a question with only a `name` renders as bare options with no question text. |
| `cvid` | string | | |
| `style` | `star` \| `scale` | yes | star = star rating; scale = a numeric scale. A "scale" question IS a rating with style:"scale" — there is no separate "scale" kind. |
| `range` | `object{ low, high }` | yes | Numeric range, e.g. `{ low: 1, high: 5 }`. |
| `lowLabel` | string | | |
| `highLabel` | string | | |
| `bindAttribute` | string | | Optional: codeName of an EXISTING attribute (create the attribute definition first) to ALSO save this answer onto the user for targeting/segmentation — use the codeName, NOT the id. The write does not check it, but the version validation WARNS when the attribute is missing or its dataType mismatches the answer — read warnings. A wrong code that slips through silently captures nothing at runtime. Match the attribute dataType to the answer: number (nps / rating), string (single-select choice), list (multi-select choice). Leaving it unset still records the answer as a response event — bind only when you need to target/segment on it. |
#### `text`
| Field | Type | Required | Description |
| --------------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `kind` | `"text"` | yes | |
| `name` | string | yes | The question's internal name / analytics label (it is the `questionName` on captured responses). It is NOT rendered to the user — the widget shows only the input (scale / options / text field), not this string. To show a visible question prompt, add a `text` block in the SAME step before the question block; a question with only a `name` renders as bare options with no question text. |
| `cvid` | string | | |
| `multiline` | boolean | yes | |
| `placeholder` | string | | |
| `buttonText` | string | | |
| `required` | boolean | | Require an answer before submit. ONLY `text` supports this — nps / rating / choice cannot be marked required. |
| `bindAttribute` | string | | Optional: codeName of an EXISTING attribute (create the attribute definition first) to ALSO save this answer onto the user for targeting/segmentation — use the codeName, NOT the id. The write does not check it, but the version validation WARNS when the attribute is missing or its dataType mismatches the answer — read warnings. A wrong code that slips through silently captures nothing at runtime. Match the attribute dataType to the answer: number (nps / rating), string (single-select choice), list (multi-select choice). Leaving it unset still records the answer as a response event — bind only when you need to target/segment on it. |
#### `choice`
| Field | Type | Required | Description |
| ------------------ | --------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `kind` | `"choice"` | yes | |
| `name` | string | yes | The question's internal name / analytics label (it is the `questionName` on captured responses). It is NOT rendered to the user — the widget shows only the input (scale / options / text field), not this string. To show a visible question prompt, add a `text` block in the SAME step before the question block; a question with only a `name` renders as bare options with no question text. |
| `cvid` | string | | |
| `options` | `object{ label, value }`\[] | yes | Each option has a human-facing `label` and a stored `value` — the `value` is what gets recorded/bound as the answer. |
| `allowMultiple` | boolean | yes | false = single-select, true = multi-select. A multi-select answer needs a `list`-typed bound attribute. |
| `enableOther` | boolean | | |
| `otherPlaceholder` | string | | |
| `shuffle` | boolean | | |
| `buttonText` | string | | |
| `bindAttribute` | string | | Optional: codeName of an EXISTING attribute (create the attribute definition first) to ALSO save this answer onto the user for targeting/segmentation — use the codeName, NOT the id. The write does not check it, but the version validation WARNS when the attribute is missing or its dataType mismatches the answer — read warnings. A wrong code that slips through silently captures nothing at runtime. Match the attribute dataType to the answer: number (nps / rating), string (single-select choice), list (multi-select choice). Leaving it unset still records the answer as a response event — bind only when you need to target/segment on it. |
## columns
A row of side-by-side columns, each holding its own blocks — the only nesting in
the block model.
| Field | Type | Required | Description |
| --------- | ----------- | -------- | ------------------------------------------------------------------------------------------------------------- |
| `id` | string | | Field-merge write handle. |
| `type` | `"columns"` | yes | |
| `columns` | Column\[] | yes | One entry per column, laid out left-to-right. Each column is a mini vertical stack of `blocks` (usually one). |
```json theme={null}
{
"type": "columns",
"columns": [
{ "width": { "unit": "percent", "value": 50 }, "blocks": [{ "type": "text", "markdown": "Left" }] },
{ "blocks": [{ "type": "image", "url": "https://example.com/x.png" }] }
]
}
```
## Shared shapes
Small objects reused across blocks.
**Dimension** — a width/height.
| Field | Type | Required | Description |
| ------- | ------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------- |
| `unit` | `percent` \| `pixels` \| `fill` | yes | `fill` ignores `value`, and is a **column-only** unit — an image/embed width or height with `fill` is rejected. |
| `value` | number | | ≥ 0. For `percent`, values over 100 are allowed (overflow). |
**Spacing** — margin/padding box (pixels; omitted sides inherit the theme).
| Field | Type | Required | Description |
| ----------------------------------- | ------- | -------- | ---------------------------------- |
| `enabled` | boolean | | Whether spacing is applied. |
| `top` / `bottom` / `left` / `right` | number | | Per-side pixels (may be negative). |
**Column** — one column inside a `columns` block.
| Field | Type | Required | Description |
| --------- | ----------------------------------------------------------------- | -------- | ------------------------ |
| `width` | Dimension | | Column width. |
| `justify` | `start` \| `center` \| `end` \| `between` \| `around` \| `evenly` | | Horizontal distribution. |
| `align` | `start` \| `center` \| `end` \| `baseline` | | Vertical alignment. |
| `padding` | Spacing | | Inner padding. |
| `blocks` | Block\[] | yes | The column's blocks. |
# Conditions & actions
Source: https://docs.usertour.io/api-reference-v2/conditions-and-actions
The reusable predicate and behavior primitives — used by start/hide rules, step triggers, and button/question actions.
**Conditions** decide *whether* something applies; **actions** decide *what
happens*. They're the same shapes everywhere they appear: version
[start/hide rules](/api-reference-v2/rules), step triggers, and button / question
`actions`.
## Conditions
A condition list is **AND** by default. Use a `group` to express **OR** (or to
nest): a group's `match` is how its children combine (`all` = AND, `any` = OR).
A group must hold **at least one** condition — an empty group is not "no
filter", it is a node that never matches, so next to an AND it makes the whole
rule unmatchable. Writing one is rejected. A version saved with an empty group
in the builder still reads back with it and draws a validate warning; writing
that list back is refused until the group is filled or dropped.
```json theme={null}
[
{ "type": "segment", "segment": "cm9f6vwed0002iejc4vg2zu3t", "in": true },
{ "type": "group", "match": "any", "conditions": [
{ "type": "current_url", "includes": ["/pricing"] },
{ "type": "current_url", "includes": ["/billing"] }
]}
]
```
#### `group`
| Field | Type | Required | Description |
| ------------ | ------------------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `type` | `"group"` | yes | |
| `match` | `all` \| `any` | yes | |
| `conditions` | [Condition](/api-reference-v2/conditions-and-actions#conditions)\[] | yes | The grouped conditions — at least one. An EMPTY group is not "no filter": it never matches, so next to an AND it makes the whole rule unmatchable, and writing one is rejected. A version saved with an empty group in the BUILDER still reads back with it (validate warns); writing that list back is refused until the group is filled or dropped. |
#### `attribute`
| Field | Type | Required | Description |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `type` | `"attribute"` | yes | |
| `scope` | `user` \| `company` \| `companyMembership` | yes | Which entity owns the attribute — `user` (the end user), `company`, or `companyMembership`. Same value as the attribute definition's `scope` (see the attribute definitions list); required to disambiguate a codeName that exists in more than one scope. |
| `attribute` | string | yes | |
| `op` | `is` \| `not` \| `contains` \| `not_contains` \| `starts_with` \| `ends_with` \| `any` \| `empty` \| `lt` \| `lte` \| `gt` \| `gte` \| `between` \| `true` \| `false` \| `includes_any` \| `includes_all` \| `not_includes_any` \| `not_includes_all` \| `less_than` \| `exactly` \| `more_than` \| `before` \| `on` \| `after` | yes | Operator — the allowed set depends on the attribute dataType. String: is \| not \| contains \| not\_contains \| starts\_with \| ends\_with \| any \| empty. Number: is \| not \| lt \| lte \| gt \| gte \| between \| any \| empty. Boolean: true \| false \| any \| empty. List: includes\_any \| includes\_all \| not\_includes\_any \| not\_includes\_all \| any \| empty. DateTime: less\_than \| exactly \| more\_than (relative — `value` is a number of days) \| before \| on \| after (`value` is an absolute date) \| any \| empty. The relative ops are ONE-SIDED bounds around (now − N days): `less_than N` = the date is AFTER now−N — so it also matches every FUTURE date, and on a future-dated attribute (a trial end, a renewal date) it is NOT "within the last N days"; `more_than N` = the date is BEFORE now−N. **"Signed up in the last N days" therefore needs BOTH bounds** — `less_than N` AND `more_than 0` in one `all` group; `less_than N` alone silently includes anyone whose date is in the future (a mis-mapped trial-end column, a clock/timezone slip), and those are exactly the users a new-user audience must not contain (observed in testing). Negative N shifts the bound into the future: the rolling "within the NEXT 7 days" window is `less_than` value "0" AND `more_than` value "-7" (two conditions, both required). The relative ops are DAY-granularity only — no unit field; for hour/minute windows use an `event` condition with a `within` (which has a `unit`). |
| `value` | string | | The comparison value (string / number-as-string / date). Omit for any/empty/true/false. |
| `value2` | string | | Upper bound for the `between` operator (`value` is the lower bound). |
| `values` | string\[] | | Values for the List operators (includes\_any / includes\_all / …). |
The same shape with `type: "event_attribute"` (and no `scope`) filters on an
**event's own** attributes — valid only inside an `event` condition's `where`.
#### `segment`
| Field | Type | Required | Description |
| --------- | ----------- | -------- | ----------- |
| `type` | `"segment"` | yes | |
| `segment` | string | yes | |
| `in` | boolean | yes | |
#### `current_url`
| Field | Type | Required | Description |
| ---------- | --------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `type` | `"current_url"` | yes | |
| `includes` | string\[] | yes | URL patterns (anchored whole-url match, NOT substring/regex). `*` = wildcard within one url part; `:name` = one path segment. A bare `*` (i.e. `["*"]`) matches EVERY page on every host incl. deep paths — the canonical always-on / whole-site pattern (use it when content should be available everywhere). Scope it down instead with: `*/` (homepage only — path exactly `/`), `*/pricing` (one page), `*/app/*` (a section + below), `host.com/*` (any page on a specific host). Multiple patterns are OR-matched: the URL matches this list if it matches ANY one pattern (so "/tasks OR /dashboard" is one condition with both patterns here — no group needed). |
| `excludes` | string\[] | | URL patterns to exclude (same syntax as includes); excludes win over includes. |
#### `element`
| Field | Type | Required | Description |
| -------- | ---------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `type` | `"element"` | yes | |
| `target` | Target | | |
| `state` | `present` \| `hidden` \| `disabled` \| `enabled` \| `clicked` \| `unclicked` | yes | `present` means NOT CLIPPED AWAY: the element is in the DOM and its box lies inside the viewport / its scroll ancestors — scrolled off-screen or `display:none` never satisfies it (and `hidden` is its negation). It is NOT "the user can see something there": an EMPTY, zero-height placeholder node satisfies `present` (observed in testing: a checklist task keyed on an initially-empty `
` status line ticked itself the moment the checklist appeared, before the shopper did anything). So do not use element presence as a proxy for "the app has said something": most apps keep the container mounted and only fill in its text. Match the TEXT instead (`target.text` + `present`, or the negation trick: the old text `hidden`). Appearances shorter than about a second can be missed entirely. `disabled`/`enabled` read the element disabled state at evaluation time. **`clicked` means "clicked since page load AND the element is STILL in the DOM right now"** — both halves, re-checked every evaluation. The click memory latches (the listener attaches the FIRST time the condition is evaluated, so earlier clicks are invisible, and the memory survives a re-render), but the element lookup is redone each poll. Two consequences, one of them silent: (1) **an element that UNMOUNTS on click can NEVER satisfy it** — the click lands, the element vanishes, the lookup fails from then on and the condition stays false forever with no error (observed in testing: a tracker on a button that clears its own toolbar counted ZERO real clicks); (2) an element that unmounts and REMOUNTS satisfies it again, so a tracker gated on it fires once per remount — not once per page load. Unlike `present`, this lookup does NOT require viewport visibility: scrolling the target off-screen keeps `clicked` true. `unclicked` negates the same pair, so it is also false while the element is absent. To count a COMPLETED action, condition on what the app shows afterwards (a success toast, a state change) rather than `clicked` on the button that starts it. |
#### `content_state`
| Field | Type | Required | Description |
| --------- | ---------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `type` | `"content_state"` | yes | |
| `content` | string | yes | contentId of the FLOW or CHECKLIST whose per-user state to check (an id from the content list). Only flows and checklists record this state — referencing a banner / launcher / resource-center / tracker is rejected at write. |
| `state` | `seen` \| `unseen` \| `completed` \| `uncompleted` \| `active` \| `inactive` | yes | The referenced flow/checklist's state for THIS user. seen = started at least once (for a flow, TRUE from the moment it opens; for a checklist, TRUE only once the user EXPANDS the panel — a `initialDisplay: "button"` checklist whose launcher is never clicked stays unseen forever); unseen = never started; active = currently open/running; inactive = NOT currently running (covers both never-started and ran-then-closed); completed = reached a goal/completion step; uncompleted = not completed. To gate piece B until flow A has run AND closed (the usual "show next thing after the welcome flow" sequencing), use `seen` AND `inactive` together — `seen` alone fires while A is still open (B piles on top), and `completed` alone strands users who skip/dismiss A. |
#### `event`
| Field | Type | Required | Description |
| -------- | -------------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `type` | `"event"` | yes | |
| `event` | string | yes | |
| `count` | `object{ op, n, n2 }` | | How many times the event must have occurred. Omit it for the common case "the event has happened" (treated as at\_least 1). Set `op`/`n` for a threshold (`between` needs `n` and `n2`). `at_least`/`between` require n ≥ 1; use `at_most`/`exactly` with 0 for "never happened". |
| `within` | `object{ op, value, value2, unit }` | | Optional time window for the event count. Omit it (or use `any_time`) to count over all time — "the event has ever happened". Any other `op` (`in_the_last` / `more_than` / `between`) requires BOTH `value` and `unit`, and `between` also `value2` — rejected at write otherwise. |
| `scope` | `current_user` \| `current_user_in_company` \| `any_user_in_company` | | Whose event activity to count (default `current_user`). `current_user` = only this user's own events. `current_user_in_company` = this user's events, but counted within their currently-associated company context (needs the user associated to a company via `group()` / the company-membership API). `any_user_in_company` = events by ANY user in this user's company — account-level activity (e.g. "anyone on the account has done X"). The two company scopes require the user to be in a company or they never match. |
| `where` | [Condition](/api-reference-v2/conditions-and-actions#conditions)\[] | | |
#### `text_input`
| Field | Type | Required | Description |
| -------- | ------------------------------------------------------------------------------------------------------------------------- | -------- | ----------- |
| `type` | `"text_input"` | yes | |
| `target` | Target | | |
| `op` | `is` \| `not` \| `contains` \| `not_contains` \| `starts_with` \| `ends_with` \| `match` \| `unmatch` \| `any` \| `empty` | yes | |
| `value` | string | | |
#### `text_filled`
| Field | Type | Required | Description |
| -------- | --------------- | -------- | ----------- |
| `type` | `"text_filled"` | yes | |
| `target` | Target | | |
#### `time_window`
| Field | Type | Required | Description |
| ------- | --------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `type` | `"time_window"` | yes | |
| `start` | string | yes | Window start (ISO datetime). REQUIRED — the runtime never matches a window without a start, so an end-only window is rejected at write. For "until X" semantics, set start to any past instant and end to X. |
| `end` | string | | Window end (ISO datetime). Omit for an open-ended "from start onwards" window. |
#### `task_clicked`
Parameterless (`{ "type": "task_clicked" }`). Valid only in a checklist item's
`completeWhen` — the item completes when clicked.
#### `unsupported`
Read-back only: a stored condition the representation cannot express is returned
as `{ "type": "unsupported", "note": "…" }` — usually a DEAD condition the
runtime never matches (deleted attribute/event, end-only time window); the
`note` says which. It cannot be written back: **echoing it is rejected**
(E1017) — the placeholder carries no data to preserve. Either remove it from
the list you write, an explicit choice that DELETES the stored condition
(mind: a never-matching node inside an AND list pins the whole rule to
"never fires", so deleting it can bring the remaining conditions to life),
or repair the original condition in the Usertour builder first. The same
contract applies to segment conditions.
## Actions
#### `goto_step`
| Field | Type | Required | Description |
| ------ | ------------- | -------- | --------------------------------------------------------------------------------------------------------------------------- |
| `type` | `"goto_step"` | yes | |
| `step` | string | yes | Target step: a step `key` declared elsewhere in the same write, or an existing step cvid. Resolved server-side to the cvid. |
#### `start_content`
| Field | Type | Required | Description |
| --------- | ----------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `type` | `"start_content"` | yes | |
| `content` | string | yes | contentId of the flow or checklist to launch (an id from the content list) — a raw content id, NOT a step key (unlike goto\_step). Must reference a flow or checklist (a banner / launcher / resource-center / tracker is rejected at write). The target must be PUBLISHED to actually start at runtime; an unknown/dangling id is rejected at validate. |
| `step` | string | | Optional cvid of a step within the launched flow to start at. |
#### `navigate`
| Field | Type | Required | Description |
| -------- | ------------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `type` | `"navigate"` | yes | |
| `url` | string | yes | Absolute URL, or an app-relative path ("/docs/x") resolved against the origin the user is on — relative paths are the normal choice for in-app navigation. |
| `newTab` | boolean | | Open the URL in a new browser tab instead of navigating the current one. |
#### `dismiss`
| Field | Type | Required | Description |
| ------ | ----------- | -------- | ----------- |
| `type` | `"dismiss"` | yes | |
`run_javascript` is **echo-only**: builder-authored script actions appear on
reads, and echoing one back **unchanged** preserves it when you rewrite the
surrounding list (action lists are full replacements — leaving it out deletes
it). Authoring a new or edited script is rejected (no API/agent-injected
JavaScript).
```json theme={null}
{ "type": "button", "text": "See pricing", "actions": [{ "type": "navigate", "url": "/pricing", "newTab": true }] }
```
## Target
An element reference (used by `element` / `text_input` / `text_filled`
conditions, tooltip steps, and banner container).
| Field | Type | Required | Description |
| ---------- | ------ | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `selector` | string | yes | A stable CSS selector for the element in your app. |
| `text` | string | | Visible text the targeted element must **equal** (exact match, after trim) — refines the element chosen by `selector`/`nth`; it does not search among multiple matches. |
| `nth` | number | | 0-based index of the match when the selector isn't unique, in document order. Range 0–4 (only the first 5 matches are addressable). |
```json theme={null}
{ "selector": "[data-tour='create']" }
```
```json theme={null}
{ "selector": "button.cta", "text": "Save", "nth": 0 }
```
# Content representation
Source: https://docs.usertour.io/api-reference-v2/content-representation
How a content version is read and written — steps, blocks, rules, and type-specific data.
A piece of **content** (flow, checklist, launcher, banner, tracker,
resource-center, announcement) has **versions**. The editable head is the *draft* version
(`editedVersionId`); publishing makes a version live in an environment.
A version's authorable body is exposed as a stable **representation** — the same
shape you read and write — rather than Usertour's internal builder model. You
**read** it with `expand`, and **write** it with
`PATCH content/{contentId}/versions/{id}` (drafts only).
## A version's body
| Content type | Body | Read with | Write field |
| ------------------------------------------------------------------------------------ | --------- | --------------- | ----------- |
| `flow` (tooltip / modal / …) | **steps** | `?expand=steps` | `steps` |
| `checklist` / `launcher` / `banner` / `tracker` / `resource-center` / `announcement` | **data** | `?expand=data` | `data` |
Both also carry version-level **start / hide rules** (`startRules`, `hideRules`)
and a `themeId`.
## Steps (flow)
```json theme={null}
{
"object": "step",
"id": "cm9...", // server-owned; the write handle (see below)
"key": "welcome", // your handle, write-only — wire goto_step by it
"name": "Welcome",
"type": "modal",
"placement": { "position": "center" },
"content": [
{ "object": "block", "type": "text", "markdown": "Welcome **{{ first_name | default: \"there\" }}**" },
{ "object": "block", "type": "button", "text": "Next", "variant": "primary", "actions": [{ "type": "goto_step", "step": "pricing" }] }
],
"triggers": [ { "when": [ /* conditions */ ], "do": [ /* actions */ ] } ]
}
```
**Blocks** (`content[]`): `text` (markdown), `image`, `button`, `embed`,
`question` (nps / rating / text / choice), and `columns` (a row of nested blocks).
Full payload per block type: **[Blocks](/api-reference-v2/blocks)**.
**Rich text** is a small markdown subset: paragraphs, `# `/`## ` headings (h1/h2
only — no h3+), `-`/`*` and `1.` lists, ` ``` ` code fences; inline `**bold**`,
`*italic*`, `[text](url)` (append `{target=_blank}` right after a link to open
it in a new tab), and a Liquid-style user-attribute placeholder
`{{ attribute | default: "fallback" }}`. Emphasis applies to placeholders too:
`**Hi {{ name }}!**` renders the whole greeting — the interpolated value
included — in bold.
### Step identifiers: `id`, `cvid`, `key`
* **`id`** — server-owned **write handle**. Echo a step's `id` to update it;
**omit** `id` to create a new step. Steps you omit from the list are deleted.
* **`cvid`** — server-owned, version-stable reference. Returned on read; survives
version copies. You never set one.
* **`key`** — **your** handle, **write-only** (not stored, not returned). Give a
step a `key` and a `goto_step` action elsewhere in the *same write* can target
it with `"step": ""` — so you can author a whole flow, including forward
and cyclic links, in one request without knowing cvids yet.
A `goto_step` `step` resolves to a `key` you sent in this request, or an existing
`cvid` — both map to the same step, so you don't have to choose. The stored value
is always the cvid (reads return cvids).
## Rules
Conditions and actions are reused everywhere (step triggers, button/question
actions, version start/hide rules):
* **Conditions** — a recursive tree (`attribute` with a `scope`, `current_url`,
`segment`, `element`, `content_state`, `event`, `text_input`, `text_filled`,
`time_window`, nested `group` with `all`/`any`).
* **Actions** — `goto_step`, `start_content`, `navigate`, `dismiss`.
(`run_javascript` is **echo-only**: builder-authored scripts appear on reads
and are preserved by echoing them back unchanged; authoring one is rejected.)
* **Start rules** (`startRules`) — when the content auto-starts, plus frequency /
priority / wait. **Hide rules** (`hideRules`) — when to hide it.
Field reference: **[Conditions & actions](/api-reference-v2/conditions-and-actions)**
and **[Start, hide & trigger rules](/api-reference-v2/rules)**.
## Type-specific data (non-flow)
For the non-flow types, the body lives in `data` (read with `?expand=data`):
checklist items, launcher target/tooltip/behavior, banner placement/content,
tracker event, or the resource-center block tree. Nested rich content reuses the
same blocks, and conditions/actions reuse the same rules.
A newly created non-flow content is seeded with its type's default `data`, so you
only send the fields you want to change — the write is a field-level merge. Full
payload per type: **[Type-specific data](/api-reference-v2/type-data)**.
## Writing
`PATCH /v2/projects/{projectId}/content/{contentId}/versions/{id}` — send only the
fields you want to change:
```json theme={null}
{
"steps": [ /* full list, merged by id */ ],
"startRules": { "when": [ /* conditions */ ] },
"hideRules": null, // null clears
"themeId": "cm7...", // switches theme (cannot be cleared)
"data": { /* for non-flow types */ }
}
```
* Writes target **draft** versions only.
* It is a **field-level merge**: styling, layout, and other details the
representation doesn't express are preserved from the existing version.
* Omitted top-level fields are left untouched.
To start a fresh draft, `POST content/{contentId}/versions` (forks the edited
version), or `POST content/{contentId}/versions/{id}/restore` (forks a historical
version forward).
Publishing is a separate, explicit step — see the **Publish a version** endpoint.
# Errors
Source: https://docs.usertour.io/api-reference-v2/errors
The v2 error envelope and codes.
Every v2 error returns a stable JSON envelope with a machine-readable `code` —
match on `code`, not on the message (messages may change):
```json theme={null}
{
"error": {
"code": "E1012",
"message": "API key lacks the required scope for this operation",
"doc_url": "https://docs.usertour.io"
}
}
```
Validation errors (`E1017`) may additionally carry an `issues` array with one
entry **per problem**, so you can fix every field in a single round-trip
instead of resubmitting once per error:
```json theme={null}
{
"error": {
"code": "E1017",
"message": "steps[0].target: selector is required; steps[1]: unknown step type",
"issues": [
{ "rule": "schema", "path": "steps[0].target", "message": "selector is required" },
{ "rule": "step_shape", "path": "steps[1]", "message": "unknown step type" }
],
"doc_url": "https://docs.usertour.io"
}
}
```
Each issue has a `rule` (which validation layer rejected it: `schema`,
`reactive_condition`, `action_not_allowed`, `step_shape`, `reference_target`,
`auto_start`, `media_url`), a `message`, and — when it maps to a request field —
a `path` into the request body.
HTTP status follows the usual conventions: `2xx` success, `4xx` your request,
`5xx` Usertour.
## Codes
| Code | Status | Meaning |
| ------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `E1000` | 403 | Invalid / unknown API token |
| `E1010` | 401 | Missing `Authorization` header |
| `E1011` | 403 | Token not scoped to the requested project |
| `E1012` | 403 | Token lacks the required scope |
| `E1019` | 403 | Environment does not belong to the project |
| `E1029` | 403 | Environment is outside the token's environment allowlist |
| `E1032` | 403 | Creating an environment needs a token WITHOUT env-targeted capabilities (user/company/session/segment/analytics, content:publish) — those must name environments, and no allowlist can cover an environment that does not exist yet; use a separate project-level-only token |
| `E0043` | 403 | The project's plan does not include the feature — today: webhook writes below the Starter plan on Usertour Cloud (reads and deletes stay available; self-hosted is never gated) |
| `E1038` | 403 | Custom CSS requires the Growth plan or above — below that the runtime strips `customCss` at delivery, so the write is refused instead of silently stored (echoing a stored value back, or clearing it, always passes) |
| `E1020` | 401 | API token has expired |
| `E1001` | 404 | User not found |
| `E1002` | 404 | Company not found |
| `E1003` | 404 | Company membership not found |
| `E1004` | 404 | Content or content version not found. The message distinguishes two states: a plain "Content not found" means the id never existed here; an "exists but is ARCHIVED (soft-deleted)" message means the content is in the trash — list it with `deleted=true`, restore it with `POST /content/{id}/restore` |
| `E1005` | 404 | Content session not found |
| `E1021` | 404 | Theme not found |
| `E1022` | 404 | Attribute definition not found |
| `E1024` | 404 | Event definition not found |
| `E1025` | 404 | Segment not found |
| `E1026` | 404 | Environment not found |
| `E1033` | 404 | Unknown API route (no /v2 endpoint matches the path) |
| `E0003` | 400 | Request is invalid against current domain state |
| `E1017` | 400 | Request validation failed (bad body / params; may carry `issues`) |
| `E1015` | 400 | Invalid `scope` filter value |
| `E0022` | 409 | Cannot delete the last environment |
| `E0023` | 409 | Cannot delete the primary environment — set another environment as primary first |
| `E0049` | 409 | Version is published (read-only) — fork a draft with the versions endpoint first |
| `E0050` | 409 | Version was modified concurrently — re-read and retry |
| `E1023` | 409 | Resource conflict (e.g. duplicate `codeName`) |
| `E1028` | 409 | Content is still published — unpublish from every environment before deleting |
| `E1030` | 409 | Event definition already has recorded events |
| `E1037` | 409 | The built-in "all" segment cannot be modified or deleted — create a condition segment for a filtered audience |
| `E1036` | 409 | Predefined attribute/event definitions cannot be modified or deleted — create your own definition instead |
| `E1034` | 409 | Cannot delete the default theme — set another theme as the project default first |
| `E1035` | 409 | System themes cannot be modified or deleted — duplicate one into your own theme (setting one as the project default is allowed) |
| `E1031` | 409 | Theme is used by live or draft content — switch that content to another theme first |
| `E1027` | 422 | Content version is not publishable (fails render-usability validation) |
| `E1013` | 429 | Rate limit exceeded — on Cloud the limit follows your plan (100/500/1000/3000 requests/min); self-hosted deployments have a flat per-token limit instead (default 1000/min, tunable via [`API_THROTTLE_LIMIT`](/open-source/env)). The standard `Retry-After` header says how long to back off |
Write endpoints validate the request body against the schema and return `E1017`
with a message pointing at the offending field — for example, a malformed
`data` body for a non-flow content type, or a step referencing an unknown value.
Bodies are validated **strictly**: an unknown top-level key (e.g. `isPrimary`
copied from a read response) is rejected with `E1017`, never silently dropped.
A body that is not valid JSON at all is also `E1017` (`Invalid JSON body: ...`).
## Pagination cursors never error
An invalid or expired `cursor` does **not** return an error — the request
succeeds with an empty final page:
```json theme={null}
{ "results": [], "next": null, "previous": null }
```
This is deliberate: a cursor the server itself issued can stop matching rows
when the underlying data is deleted mid-pagination, and from the server's side
that is indistinguishable from a mistyped cursor. Treating both as "you have
reached the end" keeps a client that is iterating `next` links from crashing
halfway through a sync. If you loop on `next`, an empty page with `next: null`
is your termination signal — don't probe cursors expecting a 4xx.
# Expanding objects
Source: https://docs.usertour.io/api-reference-v2/expanding-objects
Inline related objects and heavy fields with the expand query parameter.
## Overview
Many v2 responses can inline related objects or heavy fields with the `expand`
query parameter, so you avoid a second request. By default these fields are
omitted (or `null`).
## Usage
```bash theme={null}
# inline the version objects on a content
GET /v2/projects/{projectId}/content/{id}?expand=editedVersion
# decompiled steps on a content version (heavy → opt-in)
GET /v2/projects/{projectId}/content/{contentId}/versions/{id}?expand=steps
```
Request multiple expansions by repeating the parameter (both forms work):
```bash theme={null}
?expand=editedVersion&expand=publishedVersion
?expand[]=editedVersion&expand[]=publishedVersion
```
Comma-separated values (`?expand=editedVersion,publishedVersion`) are **not**
supported — pass each value as its own `expand` parameter.
## What each resource supports
| Resource | `expand` options |
| --------------- | -------------------------------------------------- |
| Content | `editedVersion`, `publishedVersion` |
| Content version | `questions`, `steps`, `data` |
| Content session | `answers`, `content`, `company`, `user`, `version` |
| User | `companies`, `memberships`, `memberships.company` |
| Company | `users`, `memberships`, `memberships.user` |
| Theme | `settings`, `variations` |
A **dotted** option expands one level deeper: `memberships.company` inlines each
membership **and** the company on it (it implies `memberships`). The same goes for
`memberships.user` on a company.
For content versions, `steps` (flow body) and `data` (non-flow body) are heavy
and only returned when expanded — see
[Content representation](/api-reference-v2/content-representation).
# Introduction
Source: https://docs.usertour.io/api-reference-v2/introduction
The Usertour v2 API — project-scoped, token-authenticated, and contract-first.
The **v2 API** is the current Usertour REST API. It is project-scoped,
authenticated with [personal API tokens](/api-reference-v2/authentication), and
generated from a single source of truth (so this reference always matches the
server).
**Beta.** The v2 API (and its [MCP endpoint](/api-reference-v2/mcp)) is stable
enough to build on, but the surface may still change while it's in Beta —
breaking changes will be called out in the changelog. Feedback is welcome.
Looking for the older API? See the **[legacy v1 reference](/api-reference/introduction)**
(still supported for existing integrations). v2 is recommended for everything new —
it adds content **authoring** (read *and* write), publishing, and an
[MCP](/api-reference-v2/mcp) endpoint for AI agents.
## Base URL
```text Cloud theme={null}
https://api.usertour.io
```
```text Self-hosted theme={null}
https://
```
All requests use **HTTPS**. Every v2 path is rooted at a project:
```
/v2/projects/{projectId}/...
```
Business data (users, companies, sessions) is additionally scoped to an
environment:
```
/v2/projects/{projectId}/environments/{environmentId}/...
```
## Authentication
Send a personal API token as a Bearer header:
```
Authorization: Bearer utp_xxxxxxxxxxxxxxxxxxxxxxxxxxxx
```
Tokens carry **scopes** and are bound to one or more projects. See
[Authentication](/api-reference-v2/authentication).
## Conventions
* **Pagination** — list endpoints return `{ results, next, previous }` with cursor
paging. See [Pagination](/api-reference-v2/pagination).
* **Ordering** — `orderBy` (e.g. `-createdAt`). See [Ordering](/api-reference-v2/ordering).
* **Expanding** — `expand` inlines related objects (e.g. `?expand=steps`,
`?expand=editedVersion`). See [Expanding objects](/api-reference-v2/expanding-objects).
* **Attributes** — custom fields on users/companies/events. See
[Attributes](/api-reference-v2/attributes).
* **Errors** — a stable `{ error: { code, message } }` envelope. See
[Errors](/api-reference-v2/errors).
## What's different from v1
| | v1 | v2 |
| ---------------- | --------------------------- | --------------------------------------------------------------------------- |
| Path | `/v1/...` | `/v2/projects/{projectId}/...` |
| Auth | access token (`ak_`) | personal API token (`utp_`) + scopes |
| Publish state | single `publishedVersionId` | per-environment `environments[]` |
| Content versions | read-only | read **and write** (steps, rules, theme, body) |
| Lifecycle | — | publish / unpublish, duplicate, restore, draft versions |
| Authoring | — | a stable [content representation](/api-reference-v2/content-representation) |
| AI | — | [MCP](/api-reference-v2/mcp) endpoint |
# MCP server
Source: https://docs.usertour.io/api-reference-v2/mcp
Drive Usertour from an AI agent over the Model Context Protocol.
Usertour exposes a [Model Context Protocol](https://modelcontextprotocol.io)
endpoint so AI agents (Claude, Cursor, …) can read and write your project through
the same v2 services the REST API uses.
**See it in action:** [Build Your Onboarding with AI](/build-onboarding-with-ai)
— a video of an AI assistant building a complete onboarding experience in a
real app, with the exact prompts to follow along.
**Beta.** The MCP endpoint is new and evolving with the [v2
API](/api-reference-v2/introduction) — tools and behavior may still change.
Breaking changes will be called out in the changelog. Feedback is welcome.
## Endpoint
```text Cloud theme={null}
https://mcp.usertour.io/mcp
```
```text Self-hosted theme={null}
https:///mcp
```
It speaks MCP over **Streamable HTTP**. Whichever way you connect, this is the
only URL you give your client — it discovers everything else (the authorization
server, the login) automatically.
A connection always acts in **exactly one project** (MCP carries no project in the
path): with OAuth you pick it — along with the environments and permissions the
app gets — during consent; with a personal token it's the token's project. Tools are **scope-gated** — a tool is only listed and usable if
your access includes its capability, so write tools need the matching write scope
(`content:create`, `user:write`, `segment:create`, `theme:update`,
`session:manage`, …).
**Self-hosting?** Use your own API host + `/mcp`. The discovery metadata, the
`WWW-Authenticate` challenge, and the consent redirect are all derived from
your instance's public URLs, so behind a reverse proxy make sure forwarded
headers (`X-Forwarded-Proto` / `Host`) are correct — or pin them with
[`API_URL`](/open-source/env) (every public URL) or `MCP_SERVER_URL` (just
the MCP endpoint) — and that [`APP_HOMEPAGE_URL`](/open-source/env) points
at your real, browser-reachable app URL (the consent step redirects there).
OAuth 2.1 also requires the endpoint to be reachable over **HTTPS** (loopback
is exempt).
## Connect with OAuth (one-click)
Clients that support remote MCP servers over OAuth (Claude Code, Claude's custom
connectors, Cursor, Codex, VS Code, ChatGPT, …) need **no token to copy** — you
give them the [endpoint](#endpoint) and they run the login for you. On first use
the client discovers the authorization server, registers itself
([Dynamic Client Registration](https://datatracker.ietf.org/doc/html/rfc7591)),
and opens your browser to authorize. The flow is OAuth 2.1 with PKCE.
The snippets below use the Cloud endpoint — self-hosting, substitute your own
`/mcp` URL. **Settings → MCP** in the app shows the same per-client steps with
your instance's URL pre-filled. To serve MCP on its own domain, set
`MCP_SERVER_URL` to the full public endpoint — it drives the Settings display
and the OAuth discovery metadata together, and that domain must also proxy
`/oauth/*` and `/.well-known/oauth-*` (the shipped nginx config does).
Install the [plugin](https://github.com/usertour/skills) — it registers the MCP
connection **and** the authoring skills in one:
```text theme={null}
/plugin marketplace add usertour/skills
/plugin install usertour@usertour
```
Then run `/mcp` inside Claude Code to authorize — the Usertour authorization
screen opens in your browser.
Prefer the MCP connection only (without the skills)? One shell command:
```bash theme={null}
claude mcp add --transport http usertour https://mcp.usertour.io/mcp
```
**Self-hosting?** Set your server URL in the shell you launch Claude Code from
(the plugin defaults to Cloud):
```bash theme={null}
export USERTOUR_MCP_URL="https:///mcp"
```
One click:
[**Add to Cursor**](cursor://anysphere.cursor-deeplink/mcp/install?name=usertour\&config=eyJ1cmwiOiJodHRwczovL21jcC51c2VydG91ci5pby9tY3AifQ==)
— Cursor opens with the server pre-filled and walks you through authorization.
Prefer manual setup? **Cursor → Settings → MCP → Add new MCP server**, or add to
`~/.cursor/mcp.json`:
```json theme={null}
{
"mcpServers": {
"usertour": {
"url": "https://mcp.usertour.io/mcp"
}
}
}
```
Optional: also install the authoring skill for workflow guidance:
```bash theme={null}
npx skills add https://github.com/usertour/skills
```
Register the server — shared by Codex's CLI and IDE extension (not Codex Cloud):
```bash theme={null}
codex mcp add usertour --url "https://mcp.usertour.io/mcp"
```
Codex supports OAuth natively and prompts you to authorize the first time you
use a Usertour tool. To authorize right away instead:
```bash theme={null}
codex mcp login usertour
```
Optional: also install the authoring skill for workflow guidance:
```bash theme={null}
npx skills add https://github.com/usertour/skills
```
One click: [**Add to VS Code**](vscode:mcp/install?%7B%22name%22%3A%20%22usertour%22%2C%20%22type%22%3A%20%22http%22%2C%20%22url%22%3A%20%22https%3A//mcp.usertour.io/mcp%22%7D) — VS Code prompts to
install the server with the values pre-filled.
Prefer manual setup? Open the Command Palette and run **MCP: Open User
Configuration** to open your `mcp.json`, then add this entry — the root key is
`servers`, **not**
`mcpServers` (the #1 copy-paste mistake coming from a Cursor/Claude config):
```json theme={null}
{
"servers": {
"usertour": {
"type": "http",
"url": "https://mcp.usertour.io/mcp"
}
}
}
```
Save — the server is available next time you chat with Copilot in Agent mode.
First use prompts you to authorize.
Open [chatgpt.com/plugins](https://chatgpt.com/plugins), enable **Developer
mode**, and click **New Plugin** (requires a paid ChatGPT plan). Set the name
(`Usertour`) and the MCP Server URL (`https://mcp.usertour.io/mcp`), leave
Authentication on **OAuth**, and submit — ChatGPT opens the Usertour
authorization screen. Approve to connect.
Open [Claude's connector settings](https://claude.ai/new#settings/customize-connectors)
and click **Add → Add custom connector**. Set the name (`Usertour`), paste the Server URL
(`https://mcp.usertour.io/mcp`), and click **Add** — Claude opens the Usertour
authorization screen. Pick the project, environments, and access level, then
approve.
Any client that reads an `mcpServers` config (Continue, Zed, …): add the entry
below. The authorization flow runs automatically on first use.
```json theme={null}
{
"mcpServers": {
"Usertour": {
"url": "https://mcp.usertour.io/mcp"
}
}
}
```
### Authorizing
When the browser opens, you sign in to Usertour (if you aren't already) and the
consent screen lets you shape exactly what the app gets:
* **Project** — the one project the connection may act in (fixed when you only
have one).
* **Environments** — which of that project's environments it can act on.
Environment-targeted permissions (publishing, end-user data, sessions, …)
require at least one; with several environments none are pre-selected, so
handing an agent Production is always an explicit choice.
* **Permissions** — what the app asked for, capped by your role on that
project. Everything grantable starts checked; uncheck what you don't want to
hand over, or flip **Read-only** to drop every write in one click.
The connection then acts **as you, within that grant** — the grant is a ceiling
under your role, never an extension of it: if your role is later downgraded or
you are removed from the project, the connection loses that access immediately,
exactly like a personal key.
## Managing connected apps
Every app you've authorized is listed under **Settings → Connected apps**, with
the project it can act in, the access it was granted, and when it was last used.
**Revoke** cuts it off immediately — its tokens stop working on the next call.
## Connect with a personal token
Clients that don't support OAuth (or stdio-only clients) use a personal token.
Bridge to the HTTP endpoint with `mcp-remote`:
```json theme={null}
{
"mcpServers": {
"usertour": {
"command": "npx",
"args": [
"-y",
"mcp-remote",
"https://mcp.usertour.io/mcp",
"--header",
"Authorization: Bearer utp_xxxxxxxxxxxxxxxxxxxxxxxxxxxx"
]
}
}
}
```
Use a single-project token with only the scopes the agent needs. To let it
author, include the write scopes — otherwise only read tools appear.
## Tools
The server ships a routing map as MCP **server instructions** in the initialize
handshake — which tool serves which intent, and to read `get_authoring_guide`
before authoring — so an agent starts oriented without spending a tool call.
Every tool carries MCP **annotations** (`readOnlyHint` / `destructiveHint`) so a
client can gate calls: read tools run freely, while destructive writes
(`delete_*`, `unpublish_*`, `end_session`, …) are flagged so the client can ask
for confirmation first.
**Read**
| Group | Tools |
| --------------------- | ------------------------------------------------------------------------------- |
| Guide & schemas | `get_authoring_guide`, `get_content_schema`, `get_theme_schema` |
| Diagnosis | `diagnose_content`, `diagnose_user` |
| Content | `list_content`, `get_content`, `list_publish_history` |
| Analytics | `get_content_analytics`, `get_content_question_analytics`, `get_usage_overview` |
| Versions | `list_content_versions`, `get_content_version`, `validate_content_version` |
| Users | `list_users`, `get_user` |
| Companies | `list_companies`, `get_company` |
| Segments | `list_segments`, `get_segment` |
| Sessions | `list_sessions`, `get_session` |
| Themes | `list_themes`, `get_theme` |
| Attribute definitions | `list_attribute_definitions`, `get_attribute_definition` |
| Event definitions | `list_event_definitions`, `get_event_definition` |
| Environments | `list_environments`, `get_environment` |
| Webhooks | `list_webhooks` |
| References | `list_references` |
Every `list_*` tool pages with `cursor` / `limit`. The named-resource lists —
`list_content`, `list_segments`, `list_themes`, `list_environments`,
`list_event_definitions`, `list_attribute_definitions` — also take a **`name`**
filter (case-insensitive substring against the display name). `list_content`
additionally filters by `type`, `published`, and a created-at range; `list_segments`
by `bizType`; `list_sessions` by `completed`.
`get_authoring_guide` returns the in-band conventions for building usable content
(lifecycle, step types, goto-by-key, the markdown subset, per-type requirements) —
an agent should read it before authoring.
`get_content_version` with `expand: ["steps"]` returns the decompiled steps — read
them before editing with `update_content_version`.
`diagnose_content` answers "why isn't my content showing?" by evaluating the SAME
runtime gates the SDK uses (published / user identified / start rules / frequency /
session state) as a per-gate checklist; `list_references` answers "who still uses
this attribute / event / segment / theme / content" before you delete it.
**Write**
| Group | Tools |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| Content | `create_content`, `update_content`, `delete_content`, `restore_content`, `duplicate_content`, `publish_content`, `unpublish_content` |
| Versions | `create_content_version`, `update_content_version`, `restore_content_version` |
| Users | `upsert_user`, `delete_user` |
| Companies | `upsert_company`, `delete_company`, `add_company_member`, `remove_company_member` |
| Segments | `create_segment`, `update_segment`, `delete_segment`, `add_segment_member`, `remove_segment_member` |
| Themes | `create_theme`, `update_theme`, `delete_theme` |
| Attribute definitions | `create_attribute_definition`, `update_attribute_definition`, `delete_attribute_definition` |
| Event definitions | `create_event_definition`, `update_event_definition`, `delete_event_definition` |
| Sessions | `end_session`, `delete_session` |
| Environments | `create_environment`, `update_environment`, `delete_environment` |
| Webhooks | `create_webhook`, `update_webhook`, `delete_webhook` — see [Webhooks](/developers/webhooks) |
Content write tools take the same [representation](/api-reference-v2/content-representation)
as the REST endpoints (markdown blocks, `{{ attribute }}` placeholders, rules);
`update_content_version` carries `steps`, `startRules` / `hideRules`, `themeId`,
and `data`. The rest mirror their REST bodies one-to-one. Give each step a `key`
and wire `goto_step` to it to author a multi-step flow (forward or cyclic links)
in a single call — see [step identifiers](/api-reference-v2/content-representation).
Theme `settings` are writable as a **partial patch**: send only the fields you
change (colors, fonts, sizes, …) — they're field-merged onto the current
settings, and "Auto" hover/active colors are derived server-side. Conditional
`variations` are writable too, with the same condition set the theme builder's
variation editor offers. Call `get_theme_schema` for the exact writable fields
and their ranges; media assets (avatars, logo, custom icons) are still set in
the theme builder.
`publish_content` rejects content that wouldn't render — no theme, a tooltip step
with no target, an empty checklist, a launcher with no anchor, and so on. Call
`validate_content_version` after authoring and before publishing to get the list
of `{ ok, errors, warnings }`; `errors` are exactly what blocks publish.
## Read-only by default (prompt-injection safety)
Untrusted text reaches an agent through **read** tools — session answers and
custom user/company attributes can contain anything, including instructions. To
stop an injected instruction from triggering a write, **give any agent that reads
untrusted data read-only access** — flip **Read-only** on the OAuth consent, or
mint a token with only read scopes: with no write scopes, no write tools are
listed, so there is nothing to exploit. Mint a separate write-scoped token only
for trusted automation.
## Troubleshooting (self-hosted)
```text theme={null}
Error POSTing to endpoint: ...
405 Not Allowed
...
```
The client is POSTing MCP requests to your app's web root, which serves the
admin UI and rejects POST. Almost always the configured server URL is missing
the **`/mcp` path** — it must be the full endpoint
(`https://your-usertour-host/mcp`), not the bare domain. Copy it from
**Settings → MCP**, which shows the exact URL.
```text theme={null}
SDK auth failed: Protected resource http://your-host/mcp does not match
expected https://your-host/mcp (or origin)
```
Your instance described itself with `http://` URLs while the client connected
over `https://`: the `https` scheme was lost on the way in, usually because a
TLS-terminating proxy in front of the instance (a PaaS edge, Cloudflare, a load
balancer) didn't get `X-Forwarded-Proto: https` through the whole chain. Two
fixes, either works:
* **Pin the URL** (simplest): set [`API_URL`](/open-source/env) to your public
`https://` base — it pins every public URL the instance hands out — or
`MCP_SERVER_URL` to pin just the MCP endpoint.
* **Fix the header chain**: make sure every hop forwards `X-Forwarded-Proto`
and `Host` unchanged, and that you run a current Usertour image.
If your instance is genuinely served over plain HTTP (no TLS anywhere), MCP
authorization cannot work at all — OAuth 2.1 requires HTTPS, with only
localhost exempt.
# Ordering
Source: https://docs.usertour.io/api-reference-v2/ordering
Order list results with the orderBy query parameter.
## Query parameters
The field(s) to sort by. Provide a single field or an array. Multiple fields
sort left-to-right. Ascending by default; prefix with `-` for descending. Each
endpoint documents the fields it can be ordered by.
## Examples
```bash theme={null}
# newest content first
GET /v2/projects/{projectId}/content?orderBy=-createdAt
```
```bash theme={null}
# multiple fields (array syntax)
GET /v2/projects/{projectId}/attribute-definitions?orderBy[]=displayName&orderBy[]=-createdAt
```
# Pagination
Source: https://docs.usertour.io/api-reference-v2/pagination
v2 list endpoints share one cursor-based pagination format and list object.
## Query parameters
Specifies the maximum number of items to return in a single response. The value must be between 1 and 100. If not specified, defaults to 20 items per page.
Specifies the starting point for the next page of results. The response will include items that come after (but not including) the object with this ID. To get the next page, use the ID of the last item from your previous request. The easiest way is to use the [list object's](#the-list-object) cursor field. If not provided, the API will return items from the beginning of the list.
## The list object
Every list endpoint returns a standardized object:
The requested resource objects. Never more than `limit` items.
URL of the next page (carries the `cursor`), or `null` if there is no next page.
URL of the previous page, or `null` on the first page.
```json theme={null}
{
"results": [
{ "id": "cm9f6vwed0002iejc4vg2zu3t", "object": "content", "type": "flow", "..." : "..." },
{ "id": "cm9jaftye007yrjlx3ghggigi", "object": "content", "type": "checklist", "..." : "..." }
],
"next": "/v2/projects/{projectId}/content?limit=2&cursor=cm9jaftye007yrjlx3ghggigi",
"previous": null
}
```
To page, request the URL in `next` (or pass its `cursor` back as the `cursor`
query param).
# Start, hide & trigger rules
Source: https://docs.usertour.io/api-reference-v2/rules
When a content auto-starts, when it hides, and the in-step triggers — all built from conditions and actions.
Version-level **start** and **hide** rules, plus per-step **triggers**, are how
content reacts. They reuse [conditions and actions](/api-reference-v2/conditions-and-actions).
Set them on the version with `PATCH …/versions/{id}` (`startRules`, `hideRules`;
triggers live on each step).
## Start rules
When the content auto-starts, and how often.
| Field | Type | Required | Description |
| -------------------- | ------------------------------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `when` | [Condition](/api-reference-v2/conditions-and-actions#conditions)\[] | | Auto-start when these match. A **full replacement** when present; omit it to keep the stored conditions and patch only the settings (frequency / priority / …). |
| `frequency` | [Frequency](#frequency) | | How often it may show. |
| `priority` | `highest` \| `high` \| `medium` \| `low` \| `lowest` | | Ordering when several could start at once. |
| `waitSeconds` | number | | Delay in **seconds** (not ms) before the content becomes eligible after the conditions first match. The countdown survives the conditions un-matching mid-wait, but an elapsed wait guarantees nothing — the conditions are **re-checked at show time**, and the content starts at the next moment they match again (unlike a trigger wait, which fires its actions regardless). Capped at 300 by the runtime. |
| `startIfNotComplete` | boolean | | Only start if the user hasn't completed it. |
Send `startRules: null` to clear them.
### Frequency
| Field | Type | Required | Description |
| --------- | ----------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `mode` | `once` \| `multiple` \| `unlimited` | yes | `once` = show a single time; `multiple` = up to N per window; `unlimited` = every time the conditions match. Omitting `frequency` on a write seeds the builder default (`once`) — stored explicitly, visible on read-backs. A version stored with NO frequency (legacy data only) runs with NO limit and re-starts whenever its rules match again. |
| `every` | `object{ times, duration, unit }` | | Re-show window — used by `multiple` (with `times`) and `unlimited`; ignored by `once`. `unit` ∈ `seconds`/`minutes`/`hours`/`days`. Manual and programmatic starts also count toward the `multiple` limit. |
| `atLeast` | `object{ duration, unit }` | | Quiet period: only auto-start if no OTHER content of the SAME type has been shown within this window — and since only flows accept this knob, in practice: no other FLOW. A banner / checklist / launcher showing does NOT block it. |
```json theme={null}
{
"when": [{ "type": "current_url", "includes": ["/dashboard"] }],
"frequency": { "mode": "once" },
"priority": "medium"
}
```
## Hide rules
When to hide the content (even if started). Hiding **suspends** the session
rather than ending it — when the conditions stop matching, the same session
reappears at the same step.
| Field | Type | Required | Description |
| ------ | ------------------------------------------------------------------- | -------- | ----------------------- |
| `when` | [Condition](/api-reference-v2/conditions-and-actions#conditions)\[] | yes | Hide while these match. |
Send `hideRules: null` to clear them.
## Step triggers
A step can carry **triggers** — run actions when conditions become true while the
step is showing (e.g. advance when an element appears). Triggers live on the
step's `triggers[]` (see [Content representation](/api-reference-v2/content-representation#steps-flow)).
| Field | Type | Required | Description |
| ------------- | ------------------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `when` | [Condition](/api-reference-v2/conditions-and-actions#conditions)\[] | | Fire when these match. |
| `do` | [Action](/api-reference-v2/conditions-and-actions#actions)\[] | yes | Actions to run. |
| `waitSeconds` | number | | Delay in **seconds** (not ms) between `when` matching and `do` firing. The match is latched: the actions fire after the wait even if the conditions have since stopped matching. Capped at 300. |
```json theme={null}
{
"when": [{ "type": "element", "target": { "selector": "#welcome-modal" }, "state": "present" }],
"do": [{ "type": "goto_step", "step": "step-2" }]
}
```
# Type-specific data
Source: https://docs.usertour.io/api-reference-v2/type-data
The `data` body for non-flow content — checklist, launcher, banner, tracker, resource center, and announcement.
Non-flow content carries its body in `data` (read with `?expand=data`, write with
the `data` field of `PATCH …/versions/{id}`). A newly created content is seeded
with its type's default `data`, and writes are a **field-level merge**, so you
only send the fields you want to change. Nested rich content reuses
[blocks](/api-reference-v2/blocks); conditions/actions reuse
[rules](/api-reference-v2/conditions-and-actions).
## checklist
| Field | Type | Required | Description |
| ----------------- | ------------------------------------ | -------- | ------------------------------------ |
| `buttonText` | string | | The launcher button label. |
| `initialDisplay` | `expanded` \| `button` | | Open, or collapsed to a button. |
| `completionOrder` | `any` \| `ordered` | | Whether items must be done in order. |
| `preventDismiss` | boolean | | Disallow dismissing. |
| `autoDismiss` | boolean | | Dismiss when all items complete. |
| `content` | [Block](/api-reference-v2/blocks)\[] | | Header/intro content. |
| `items` | [Item](#checklist-item)\[] | | The checklist tasks. |
### Checklist item
| Field | Type | Required | Description |
| -------------- | ------------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `id` | string | | Field-merge write handle (omit to add a new item). |
| `name` | string | yes | Item label. |
| `description` | string | | Sub-text. |
| `completeWhen` | [Condition](/api-reference-v2/conditions-and-actions#conditions)\[] | | Marks the item complete when these match. Also accepts the parameterless `{ "type": "task_clicked" }` (complete when the item is clicked), including inside an OR group. |
| `clickActions` | [Action](/api-reference-v2/conditions-and-actions#actions)\[] | | Run when the item is clicked. |
| `onlyShowWhen` | [Condition](/api-reference-v2/conditions-and-actions#conditions)\[] | | Only show the item while these match. |
An item needs a `name` **and** either a `clickActions` or a `completeWhen`.
```json theme={null}
{
"buttonText": "Get started",
"initialDisplay": "expanded",
"items": [
{ "name": "Create your first project", "clickActions": [{ "type": "navigate", "url": "/projects/new" }], "completeWhen": [{ "type": "task_clicked" }] },
{ "name": "Invite a teammate", "completeWhen": [{ "type": "event", "event": "member_invited" }] }
]
}
```
## launcher
| Field | Type | Required | Description |
| ------------ | ---------------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------- |
| `style` | `beacon` \| `icon` \| `hidden` \| `button` | | Visual style. |
| `icon` | `object{ source, url, type }` | | Icon config. |
| `buttonText` | string | | Label (for `button` style). |
| `target` | [Target](/api-reference-v2/conditions-and-actions#target) | | Element the launcher anchors to. |
| `zIndex` | integer | | Stacking order. |
| `tooltip` | `object{ placement, width, reference, content, settings }` | | Tooltip shown on activation — `content` is [Block](/api-reference-v2/blocks)\[]. |
| `behavior` | `object{ triggerElement, event, action, actions }` | | What activates it and what it does — `actions` is [Action](/api-reference-v2/conditions-and-actions#actions)\[]. |
A launcher needs a `target`; show-tooltip behavior needs tooltip `content`,
perform-action behavior needs `actions`.
```json theme={null}
{
"style": "icon",
"target": { "selector": "[data-tour='help']" },
"tooltip": { "content": [{ "type": "text", "markdown": "Need help? Start here." }] }
}
```
## banner
| Field | Type | Required | Description |
| ----------------- | ---------------------------------------------------------------------------- | -------- | -------------------------------- |
| `placement` | [Placement](/api-reference-v2/content-representation) | | Where the banner sits. |
| `content` | [Block](/api-reference-v2/blocks)\[] | | Banner content. |
| `zIndex` | integer | | Stacking order. |
| `settings` | `object{ overlayOverAppContent, stickToTop, allowDismiss, animateOnAppear }` | | Behavior toggles. |
| `containerTarget` | [Target](/api-reference-v2/conditions-and-actions#target) | | For element-relative placements. |
| `layout` | `object{ maxContentWidth, maxEmbedWidth, borderRadius, outerMargin }` | | Sizing. |
A banner needs `content`; element-relative placements need a `containerTarget`.
```json theme={null}
{
"content": [{ "type": "text", "markdown": "🎉 New feature live!" }],
"settings": { "stickToTop": true, "allowDismiss": true }
}
```
## tracker
A tracker has no UI — it records an event when its [start rules](/api-reference-v2/rules)
match. (No theme needed.)
| Field | Type | Required | Description |
| ------- | ------ | -------- | -------------------- |
| `event` | string | yes | Event code to track. |
```json theme={null}
{ "event": "activated" }
```
## resource-center
| Field | Type | Required | Description |
| ------------ | ------------------------------------- | -------- | ------------------------------------ |
| `buttonText` | string | | Launcher button label. |
| `headerText` | string | | Panel header. |
| `tabs` | `object{ id, name, icon, blocks }`\[] | | Tabs — each has a name and ≥1 block. |
Tab blocks use the resource center's **own** vocabulary — `richtext`, `divider`,
`action`, `sub-page`, `content-list`, `live-chat`, and `announcement` (the feed;
at most one per resource center) — **not** the flow blocks. Rich text goes
inside a `richtext` block, which wraps regular [blocks](/api-reference-v2/blocks)
in its `content`; a bare flow `text` block at the tab level is rejected.
A resource center needs ≥1 tab, each with a name and at least one block.
```json theme={null}
{
"headerText": "Help & resources",
"tabs": [
{
"name": "Guides",
"blocks": [
{ "type": "richtext", "content": [{ "type": "text", "markdown": "## Getting started" }] },
{ "type": "content-list", "name": "Tours", "items": [{ "content": "cm9f6vwed0002iejc4vg2zu3t", "contentType": "flow" }] }
]
}
]
}
```
## announcement
The announcement-feed body. Announcements reach users **only** through a
resource center with an `announcement` block — publishing alone does not
surface them.
| Field | Type | Required | Description |
| ---------------- | ------------------------------------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `title` | string | | Feed-row / detail title. **Required to publish.** Plain string — no `{{ }}` interpolation. Seeded from the content name at create, then independent. |
| `introContent` | [Block](/api-reference-v2/blocks)\[] | | Feed-row content — the flow block vocabulary minus questions (button actions: `start_content` / `navigate` / `run_javascript` only). |
| `enableReadMore` | boolean | | Adds a "Read more" button opening a detail page. Enabling it with an empty `detailContent` is rejected. |
| `readMoreLabel` | string | | The button's label. |
| `detailContent` | [Block](/api-reference-v2/blocks)\[] | | The detail page (same block rules as `introContent`). |
| `distribution` | `silent` \| `badge` \| `popup` | | How loudly users are notified (`badge` is the default). |
The "announcement time" is **not** in `data` — it is the version-level
`scheduledAt` field (the feed hides the announcement until that instant and
orders by it, newest first).
```json theme={null}
{
"title": "Dark mode is here",
"introContent": [{ "type": "text", "markdown": "Flip it on in **Settings → Appearance**." }],
"distribution": "badge"
}
```
# Get content analytics
Source: https://docs.usertour.io/api-reference/analytics/get-content-analytics
/api-reference-v2/openapi.json get /v2/projects/{projectId}/content/{id}/analytics
The response shape follows the content type (discriminated on `contentType`): flows report starts + completions and a per-step funnel with tooltip-target-missing counts; checklists starts + completions and per-task rows; launchers seen + activations; banners seen + dismissals; resource centers opens + block clicks; trackers users + occurrences of the tracked event. All with a per-day series. Defaults to the last 30 days, UTC.
# Get question analytics
Source: https://docs.usertour.io/api-reference/analytics/get-question-analytics
/api-reference-v2/openapi.json get /v2/projects/{projectId}/content/{id}/analytics/questions
Per-question aggregates for survey questions in this content: answer distribution, NPS score with promoter/passive/detractor shares, rating averages — each with a rolling-window daily series (CUMULATIVE over the trailing `rollingWindowDays`, echoed per series — unlike the content-analytics per-day byDay). Defaults to the last 30 days, UTC.
# Create an attribute definition
Source: https://docs.usertour.io/api-reference/attribute-definitions/create-an-attribute-definition
/api-reference-v2/openapi.json post /v2/projects/{projectId}/attribute-definitions
# Delete an attribute definition
Source: https://docs.usertour.io/api-reference/attribute-definitions/delete-an-attribute-definition
/api-reference-v2/openapi.json delete /v2/projects/{projectId}/attribute-definitions/{id}
# Get an attribute definition
Source: https://docs.usertour.io/api-reference/attribute-definitions/get-an-attribute-definition
/api-reference-v2/openapi.json get /v2/projects/{projectId}/attribute-definitions/{id}
# List attribute definitions
Source: https://docs.usertour.io/api-reference/attribute-definitions/list
Get /v1/attribute-definitions
Retrieves a paginated list of attribute definitions in your account. Each definition includes its data type, scope, and metadata.
This endpoint returns a paginated list of attribute definitions. Each definition object contains its data type, scope, and metadata.
## Query parameters
Filter definitions by their scope. Available scopes:
* `eventDefinition`: Event-related attributes
* `company`: Company-related attributes
* `companyMembership`: Company membership attributes
* `user`: User-related attributes
Filter definitions by event name(s). You can provide:
* Single value: `eventName=page_viewed`
* Multiple values: `eventName[]=page_viewed&eventName[]=clicked`
Sort the results by one or more fields. Available fields:
* `createdAt`: Creation timestamp
* `codeName`: Technical name
* `displayName`: Display name
Examples:
* Single field: `orderBy=createdAt` or `orderBy=-createdAt` (descending)
* Multiple fields: `orderBy[]=-createdAt&orderBy[]=name`
Specifies the maximum number of items to return in a single response. The value must be between 1 and 100. If not specified, defaults to 20 items per page.
Specifies the starting point for the next page of results. The response will include items that come after (but not including) the object with this ID. To get the next page, use the ID of the last item from your previous request. The easiest way is to use the [list object's](#the-list-object) cursor field. If not provided, the API will return items from the beginning of the list.
## Response
Returns a [list object](/api-reference/pagination#the-list-object) containing an array of [attribute definition objects](/api-reference/attribute-definitions/model) in the `results` property. The response includes pagination information in the `next` and `previous` fields.
```bash Request theme={null}
# List attribute definitions for specific events
curl https://api.usertour.io/v1/attribute-definitions?scope=eventDefinition&eventName%5B%5D=page_viewed&eventName%5B%5D=flow_started&limit=3 \
-H 'Authorization: Bearer ak_123456789' \
-H 'Content-Type: application/json'
```
```json Response theme={null}
{
"results": [
{
"id": "cm9e922or000jmjaox6ynbld2",
"object": "attributeDefinition",
"createdAt": "2025-04-12T13:26:30.939Z",
"dataType": "string",
"description": "Usertour flow id",
"displayName": "Flow ID",
"codeName": "flow_id",
"scope": "eventDefinition"
},
{
"id": "cm9e922or000kmjao3nhw7w74",
"object": "attributeDefinition",
"createdAt": "2025-04-12T13:26:30.939Z",
"dataType": "string",
"description": "Usertour flow name",
"displayName": "Flow Name",
"codeName": "flow_name",
"scope": "eventDefinition"
},
{
"id": "cm9e922or000lmjao49o5mwwf",
"object": "attributeDefinition",
"createdAt": "2025-04-12T13:26:30.939Z",
"dataType": "string",
"description": "A session of a specific user viewing a specific flow",
"displayName": "Flow Session ID",
"codeName": "flow_session_id",
"scope": "eventDefinition"
}
],
"next": "/v1/attribute-definitions?scope=eventDefinition&eventName%5B%5D=page_viewed&eventName%5B%5D=flow_started&limit=3&cursor=cm9e922or000lmjao49o5mwwf",
"previous": null
}
```
# List attribute definitions
Source: https://docs.usertour.io/api-reference/attribute-definitions/list-attribute-definitions
/api-reference-v2/openapi.json get /v2/projects/{projectId}/attribute-definitions
# The attribute definition object
Source: https://docs.usertour.io/api-reference/attribute-definitions/model
Attribute definitions are metadata objects that describe the structure and purpose of attributes in your application. They are automatically created when you send new attributes through the Usertour API or SDK.
These definitions serve as a schema registry for your attributes, ensuring consistency in attribute usage across your application. While you can't directly manage attribute definitions through the API, they are automatically maintained as you use attributes.
A unique identifier for the attribute definition.
The object type identifier. Always set to "attributeDefinition" to distinguish it from other API objects.
The timestamp when the attribute definition was first created, in ISO 8601 format (YYYY-MM-DDTHH:mm:ss.sssZ).
The data type of the attribute value. For custom attributes, see [Attribute data types](/api-reference/attributes) for supported values.
A technical description of the attribute's purpose and context. This field is editable in the Usertour Dashboard and helps maintain documentation of your attribute implementation.
The human-readable identifier for the attribute, used in the Usertour Dashboard and analytics interfaces. This can be modified to better reflect your attribute naming conventions.
The immutable identifier used in your code when setting attribute values. This value is used in the attributes object when creating or updating users, and cannot be changed after creation to maintain data consistency.
Defines which objects this attribute can be associated with. Supported values:
* eventDefinition
* company
* companyMembership
* user
```json Example attribute definition object theme={null}
{
"id": "cm9e922or000mmjaopovxm7kv",
"object": "attributeDefinition",
"createdAt": "2025-04-12T13:26:30.939Z",
"dataType": "string",
"description": "Describes why a flow started",
"displayName": "Flow Start Reason",
"codeName": "flow_start_reason",
"scope": "eventDefinition"
}
```
# Update an attribute definition
Source: https://docs.usertour.io/api-reference/attribute-definitions/update-an-attribute-definition
/api-reference-v2/openapi.json patch /v2/projects/{projectId}/attribute-definitions/{id}
# Attributes
Source: https://docs.usertour.io/api-reference/attributes
Objects in the API, such as users, companies, and events, can store custom attributes in the ```attributes``` object. This allows you to extend the default data model with your own fields.
## Attribute naming
Attribute names must follow these rules:
* Only use alphanumeric characters (a-z, A-Z, 0-9)
* Can include underscores, dashes, and spaces
* Are case-sensitive
* Must be unique within the `attributes` object
We recommend using `snake_case` for all attribute names to maintain consistency across your application. For example:
* `user_id`
* `created_at`
* `last_login_time`
You can configure human-friendly display names for attributes in the Usertour UI. For instance, `signed_up_at` can be displayed as "Signed Up" in the interface.
## Attribute data types
We support the following attribute data types:
| Type | Description |
| ---------- | ------------------------------------------------------------------------ |
| `string` | Represents a string. |
| `number` | Represents a number (supports both integers and floating point numbers). |
| `boolean` | Represents either `true` or `false`. |
| `datetime` | Represents a point in time, always stored as ISO 8601 in UTC. |
| `list` | Represents a list of strings. |
## Best practices
1. **Naming conventions**
* Use `snake_case` for all attribute names
* Choose descriptive names that clearly indicate the data's purpose
* Avoid using reserved words or system field names
2. **Data types**
* Use the most appropriate data type for your data
* Store dates as `datetime` type, not as strings
* Use `boolean` for true/false values instead of strings
3. **Performance considerations**
* Keep attribute names concise but descriptive
* Avoid storing large amounts of data in attributes
* Use appropriate data types to optimize storage and query performance
# Add a company member
Source: https://docs.usertour.io/api-reference/companies/add-a-company-member
/api-reference-v2/openapi.json put /v2/projects/{projectId}/environments/{environmentId}/companies/{id}/memberships/{userId}
Add a user to the company, or update the membership (idempotent). Returns the membership (external ids + attributes). Membership attributes with an unknown codeName auto-create a definition (dataType inferred from the value) — a typo'd codeName therefore silently creates a new attribute while the real one is not updated.
# Upsert a company
Source: https://docs.usertour.io/api-reference/companies/create
Post /v1/companies
Create a new company or update an existing one. This endpoint uses an upsert operation, creating a new company if the ID doesn't exist, or updating the existing company if it does.
The create/update endpoint provides a unified way to manage companies in your system. When you send a request:
* If the company ID doesn't exist, a new company will be created
* If the company ID exists, the provided attributes will be merged with existing ones
* Existing attributes not included in the request will remain unchanged
Companies can also be created or updated indirectly through:
* User creation/update operations with embedded company data
* Membership creation/update operations
## Request body
Unique identifier for the company. We recommend using a consistent prefix (e.g., `comp_`) and matching the ID from your system for easier tracking and management.
A map of company attributes to create or update. You can include any custom attributes to describe the company. See [Attributes](/api-reference/attributes) for detailed information about attribute types and best practices.
## Response
Returns the created or updated [company object](/api-reference/companies/model) with all its fields and relationships.
```bash Request theme={null}
curl https://api.usertour.io/v1/companies \
-X POST \
-H 'Authorization: Bearer ak_123456789' \
-H 'Content-Type: application/json' \
-d '{
"id": "comp_123456789",
"attributes": {
"name": "Acme Corporation",
"industry": "Technology",
"employee_count": 500,
"subscription_tier": "enterprise",
"billing_country": "US",
"founded_at": "2020-01-01T00:00:00.000Z"
}
}'
```
```json Response theme={null}
{
"id": "comp_123456789",
"object": "company",
"attributes": {
"name": "Acme Corporation",
"industry": "Technology",
"employee_count": 500,
"subscription_tier": "enterprise",
"billing_country": "US",
"founded_at": "2020-01-01T00:00:00.000Z"
},
"createdAt": "2024-03-20T08:30:00.000Z",
"memberships": null,
"users": null
}
```
# Create or update a company
Source: https://docs.usertour.io/api-reference/companies/create-or-update-a-company
/api-reference-v2/openapi.json put /v2/projects/{projectId}/environments/{environmentId}/companies/{id}
# Delete a company
Source: https://docs.usertour.io/api-reference/companies/delete
Delete /v1/companies/:id
Permanently remove a company from the system. This operation cannot be undone, and all associated memberships will be deleted.
This endpoint permanently deletes a company and its associated data. When a company is deleted:
* All company memberships are automatically removed
* The company's attributes and relationships are permanently deleted
* The operation cannot be reversed
## Path parameters
The unique identifier of the company to delete. This should match the ID format used in your system (e.g., `comp_123456789`).
## Response
Returns a confirmation object indicating the successful deletion of the company.
The unique identifier of the deleted company.
String representing the object's type. Always set to `"company"`.
Indicates the success of the deletion operation. Always `true` when the request succeeds.
```bash Request theme={null}
curl https://api.usertour.io/v1/companies/comp_123456789 \
-X DELETE \
-H 'Authorization: Bearer ak_123456789' \
-H 'Content-Type: application/json'
```
```json Response theme={null}
{
"id": "comp_123456789",
"object": "company",
"deleted": true
}
```
# Delete a company
Source: https://docs.usertour.io/api-reference/companies/delete-a-company
/api-reference-v2/openapi.json delete /v2/projects/{projectId}/environments/{environmentId}/companies/{id}
DESTRUCTIVE and permanent — removes the company, every membership in it (with their membership attributes) and its segment rows. Users themselves survive. There is no restore; re-grouping the same external id later starts a brand-new company.
# Get a company
Source: https://docs.usertour.io/api-reference/companies/get
Get /v1/companies/:id
Retrieve detailed information about a specific company. The endpoint returns a 404 Not Found response if the company does not exist.
This endpoint retrieves a single company's information from the system. You can use the `expand` query parameter to include related objects such as memberships and users in the response.
## Path parameters
The unique identifier of the company to retrieve. This should match the ID format used in your system (e.g., `comp_123456789`).
## Response
Returns the [company object](/api-reference/companies/model) with its current state, including:
* Company metadata and attributes
* Creation timestamp
* Related memberships and users (if expanded)
```bash Request theme={null}
curl https://api.usertour.io/v1/companies/comp_123456789 \
-H 'Authorization: Bearer ak_123456789' \
-H 'Content-Type: application/json'
```
```json Response theme={null}
{
"id": "comp_123456789",
"object": "company",
"attributes": {
"name": "Acme Corporation",
"industry": "Technology",
"employee_count": 500,
"subscription_tier": "enterprise",
"billing_country": "US",
"founded_at": "2020-01-01T00:00:00.000Z"
},
"createdAt": "2024-03-20T08:30:00.000Z",
"users": null,
"memberships": null
}
```
# Get a company
Source: https://docs.usertour.io/api-reference/companies/get-a-company
/api-reference-v2/openapi.json get /v2/projects/{projectId}/environments/{environmentId}/companies/{id}
# List companies
Source: https://docs.usertour.io/api-reference/companies/list
Get /v1/companies
Retrieve a paginated list of companies in your account. The response includes company details and pagination information.
This endpoint returns a paginated list of companies associated with your account. Each company object includes its attributes, relationships, and metadata.
## Query parameters
Specifies the maximum number of items to return in a single response. The value must be between 1 and 100. If not specified, defaults to 20 items per page.
Specifies the starting point for the next page of results. The response will include items that come after (but not including) the object with this ID. To get the next page, use the ID of the last item from your previous request. The easiest way is to use the [list object's](#the-list-object) cursor field. If not provided, the API will return items from the beginning of the list.
See [Expanding objects](/api-reference/expanding-objects). Available options:
* `memberships`: Include basic membership data for each company
* `memberships.user`: Include both membership and associated user details
* `users`: Include all users associated with the company
* You can request multiple expansions: `expand[]=memberships&expand[]=users`
## Response
Returns a [list object](/api-reference/pagination#the-list-object) containing an array of [company objects](/api-reference/companies/model) in the `results` property. The response includes pagination information in the `next` and `previous` fields.
```bash Request theme={null}
curl https://api.usertour.io/v1/companies \
-H 'Authorization: Bearer ak_123456789' \
-H 'Content-Type: application/json'
```
```json Response theme={null}
{
"results": [
{
"id": "comp_123456789",
"object": "company",
"attributes": {
"name": "Acme Corp",
"domain": "acme.com",
"industry": "Technology",
"size": "100-500",
"created_at": "2024-03-20T08:30:00.000Z"
},
"createdAt": "2024-03-20T08:30:00.000Z",
"updatedAt": "2024-03-20T08:30:00.000Z"
},
{
"id": "comp_987654321",
"object": "company",
"attributes": {
"name": "TechStart Inc",
"domain": "techstart.io",
"industry": "Software",
"size": "50-100",
"created_at": "2024-03-19T15:45:00.000Z"
},
"createdAt": "2024-03-19T15:45:00.000Z",
"updatedAt": "2024-03-19T15:45:00.000Z"
}
],
"next": "/v1/companies?limit=2&cursor=comp_987654321",
"previous": null
}
```
# List companies
Source: https://docs.usertour.io/api-reference/companies/list-companies
/api-reference-v2/openapi.json get /v2/projects/{projectId}/environments/{environmentId}/companies
# The company object
Source: https://docs.usertour.io/api-reference/companies/model
Companies represent organizational units in your system. They can be used to group users, manage permissions, and orchestrate user flows based on company-specific conditions.
## Overview
Companies serve as organizational containers that group users together. In a business context, companies can represent:
* Organizations
* Teams
* Departments
* Business units
Companies support custom attributes and can be associated with events, enabling sophisticated flow orchestration based on:
* Company-wide metrics and conditions
* User behavior patterns within the company
* Cross-company relationships and hierarchies
## Object structure
Unique identifier for the company. Must match the company's ID in your system. We recommend using a consistent prefix (e.g., `comp_`) for better identification.
String representing the object's type. Always set to `"company"`.
A map of company-specific attributes. You can store any custom data to describe the company, such as:
* Company metadata (name, industry, size)
* Business metrics (revenue, employee count)
* Custom flags and settings
ISO 8601 timestamp in UTC indicating when the company was created in the system. For tracking when the company was created in your application, use a custom attribute like `company_created_at` in the attributes object.
An array of [membership objects](/api-reference/company-memberships/model) representing user-company relationships. Each membership can store custom attributes that describe the relationship between the user and company (e.g., join date, status, preferences). The field defaults to `null` but can be [expanded](/api-reference/expanding-objects) using:
* `?expand=memberships` to include basic membership data
* `?expand=memberships.user` to include both membership and user details
An array of [user objects](/api-reference/users/model) representing all members of this company. Use this field when you only need user data without membership attributes. The field defaults to `null` but can be [expanded](/api-reference/expanding-objects) using `?expand=users`.
```json Example company object theme={null}
{
"id": "comp_123456789",
"object": "company",
"attributes": {
"name": "Acme Corporation",
"industry": "Technology",
"employee_count": 500,
"founded_at": "2020-01-01T00:00:00.000Z",
"subscription_tier": "enterprise",
"billing_country": "US"
},
"createdAt": "2024-03-20T08:30:00.000Z",
"users": [
{
"id": "usr_987654321",
"object": "user",
"attributes": {
"name": "John Doe",
"email": "john.doe@acme.com",
"role": "admin",
"department": "engineering",
"last_login_at": "2024-03-20T08:30:00.000Z"
},
"createdAt": "2024-03-19T15:45:00.000Z"
}
],
"memberships": null
}
```
# Remove a company member
Source: https://docs.usertour.io/api-reference/companies/remove-a-company-member
/api-reference-v2/openapi.json delete /v2/projects/{projectId}/environments/{environmentId}/companies/{id}/memberships/{userId}
Remove a user from the company. DESTRUCTIVE, not a hide: the membership record and its membership-scoped attributes (e.g. a role) are deleted for good — re-adding creates a brand-new membership with empty attributes and a new join date, so remove-then-re-add is NOT an undo.
# Upsert a membership
Source: https://docs.usertour.io/api-reference/company-memberships/create
Post /v1/company-memberships
Create or update a company membership. Note that memberships are managed through user operations rather than direct API calls.
Company memberships are managed indirectly through user operations. To create or update a membership:
1. Use the [create or update a user ](/api-reference/users/create) endpoints
2. Include the membership details in the `memberships` field of the user object
3. The system will automatically handle the membership creation/update
This approach ensures data consistency and maintains proper relationships between users and companies.
```json Request theme={null}
{
"name": "John Doe",
"email": "john@example.com",
"memberships": [
{
"company_id": "comp_123456789",
"role": "admin"
}
]
}
```
# Remove a membership
Source: https://docs.usertour.io/api-reference/company-memberships/delete
Delete /v1/company-memberships
Removes a single user from a single company. Leaves the user and the company themselves intact.
## Query parameters
Unique identifier for the user. Should match the ID the user has in your database.
Unique identifier for the group. Should match the ID the group has in your database.
## Response
Returns an object with a deleted key on success (or if the user already wasn't a member of the group - this call is idempotent).
The unique identifier of the membership.
String representing the object's type. Always set to `"companyMembership"`.
Indicates the success of the deletion operation. Always `true` when the request succeeds.
```bash Request theme={null}
curl https://api.usertour.io/v1/company-memberships?userId=user-1746490725519&companyId=group-555555 \
-X DELETE \
-H 'Authorization: Bearer ak_123456789' \
-H 'Content-Type: application/json'
```
```json Response theme={null}
{
"id": membership_123456789",
"object": "companyMembership",
"deleted": true
}
```
# List memberships
Source: https://docs.usertour.io/api-reference/company-memberships/list
Get /v1/company-memberships
Retrieve company memberships by expanding the memberships field of a user or company object. Direct listing of memberships is not supported.
Company memberships are accessed through their parent objects (users or companies) using the [expansion](/api-reference/expanding-objects) feature. This design ensures proper data relationships and access control.
## Retrieving Memberships
### Through Users
To get a user's memberships:
1. Fetch the user using the [user list endpoint](/api-reference/users/list)
2. Include `?expand=memberships` in the request URL
3. The response will include the expanded memberships array
```bash Request theme={null}
curl https://api.usertour.io/v1/users/usr_123456789?expand=memberships \
-H 'Authorization: Bearer ak_123456789' \
-H 'Content-Type: application/json'
```
### Through Companies
To get a company's memberships:
1. Fetch the company using the [company list endpoint](/api-reference/companies/list)
2. Include `?expand=memberships` in the request URL
3. The response will include the expanded memberships array
```bash Request theme={null}
curl https://api.usertour.io/v1/companies/comp_123456789?expand=memberships \
-H 'Authorization: Bearer ak_123456789' \
-H 'Content-Type: application/json'
```
## Response
The expanded memberships will be included in the response as an array of [membership objects](/api-reference/company-memberships/model).
```json Response theme={null}
{
"id": "usr_123456789",
"object": "user",
"attributes": {
"name": "John Doe",
"email": "john@example.com"
},
"memberships": [
{
"id": "mem_123456789",
"object": "membership",
"company_id": "comp_123456789",
"role": "admin",
"created_at": "2024-03-20T08:30:00.000Z"
}
]
}
```
# The company membership object
Source: https://docs.usertour.io/api-reference/company-memberships/model
Company memberships represent the relationship between users and companies. They allow you to store custom attributes that describe how a user interacts with a specific company.
## Overview
Company memberships establish a flexible many-to-many relationship model between users and companies. This design allows:
* A single user to belong to multiple companies
* A company to have multiple user members
* Different roles and permissions for the same user across different companies
The membership object serves as the ideal place to store relationship-specific data, particularly user roles and permissions. For instance, a user might be an Owner in one company while serving as an Admin in another. This role information belongs in the membership attributes rather than the user or company objects, as it represents the specific relationship between a user and a company.
Company memberships serve as the connection between users and companies, enabling you to:
* Track user-company relationships
* Store relationship-specific attributes
* Manage user access and permissions
* Track membership history and changes
## Object structure
Unique identifier for the membership. The system automatically generates this ID when a new membership is created.
String representing the object's type. Always set to `"companyMembership"`.
A map of custom attributes that describe the user's relationship with the company. You can store any data relevant to this specific membership, such as:
* Membership status and type
* Access levels and permissions
* Department and role information
* Custom preferences and settings
ISO 8601 timestamp in UTC indicating when the membership was created in the system. For tracking when the user joined your application, use a custom attribute like `joined_at` in the user's attributes object.
The associated [company object](/api-reference/companies/model). This field defaults to `null` but can be expanded using `?expand=company` to include the full company details.
The unique identifier of the associated company. This ID can be used to reference the company in other API calls.
The associated [user object](/api-reference/users/model). This field defaults to `null` but can be expanded using `?expand=user` to include the full user details.
The unique identifier of the associated user. This ID can be used to reference the user in other API calls.
```json Example company membership object theme={null}
{
"id": "mem_123456789",
"object": "companyMembership",
"attributes": {
"role": "admin",
"department": "engineering",
"join_date": "2024-01-15T00:00:00.000Z",
"access_level": "full",
"status": "active"
},
"createdAt": "2024-03-20T08:30:00.000Z",
"company": null,
"companyId": "comp_987654321",
"user": null,
"userId": "usr_123456789"
}
```
# Delete a content session
Source: https://docs.usertour.io/api-reference/content-sessions/delete
Delete /v1/content-sessions/:id
Permanently deletes a content session and all its associated data (progress, survey answers, etc.). This operation cannot be undone. The API is idempotent - attempting to delete a non-existent session will return a success response.
## Path Parameters
The unique identifier of the content session to delete.
## Response
Returns a deletion confirmation object with the following fields:
The unique identifier of the deleted content session.
String representing the object's type. Always set to `contentSession`.
Indicates the success of the deletion operation. Always `true` when the request succeeds.
```bash Request theme={null}
# Delete a content session
curl https://api.usertour.io/v1/content-sessions/cmaavyi16003twg0bgimbcdnr \
-X DELETE \
-H 'Authorization: Bearer ak_123456789' \
-H 'Content-Type: application/json'
```
```json Response theme={null}
{
"id": "cmaavyi16003twg0bgimbcdnr",
"object": "contentSession",
"deleted": true
}
```
# End a content session
Source: https://docs.usertour.io/api-reference/content-sessions/end
Post /v1/content-sessions/:id/end
Marks a content session as completed and records its completion time. This operation is idempotent - calling it multiple times on the same session will not cause any issues.
## Path Parameters
The unique identifier of the content session to end.
## Response
Returns the updated [content session object](/api-reference/content-sessions/model) with the following changes:
* `completed` set to `true`
* `completedAt` set to the current timestamp
* `lastActivityAt` updated to the current timestamp
```bash Request theme={null}
# End a content session
curl https://api.usertour.io/v1/content-sessions/cmaavyi16003twg0bgimbcdnr/end \
-X POST \
-H 'Authorization: Bearer ak_123456789' \
-H 'Content-Type: application/json'
```
```json Response theme={null}
{
"id": "cmaavyi16003twg0bgimbcdnr",
"object": "contentSession",
"answers": null,
"completedAt": "2025-05-05T09:36:13.016Z",
"completed": true,
"contentId": "cm9f6vwed0002iejc4vg2zu3t",
"content": null,
"createdAt": "2025-05-05T09:36:13.002Z",
"companyId": "group-1746437717425",
"company": null,
"isPreview": false,
"lastActivityAt": "2025-05-05T09:36:13.016Z",
"progress": 11,
"userId": "user-1746437717384",
"user": null,
"versionId": "cm9mjsv7d001bvfe8qe8tgzv9",
"version": null
}
```
# Get a content session
Source: https://docs.usertour.io/api-reference/content-sessions/get
Get /v1/content-sessions/:id
Retrieves a specific content session by its ID, including all its attributes and relationships.
## Path Parameters
The unique identifier of the content session to retrieve.
## Query Parameters
Include additional related objects in the response. Available options:
* `content`: Include the associated [content object](/api-reference/content/model)
* `version`: Include the [content version](/api-reference/content-versions/model) details
* `answers`: Include [survey answers](/api-reference/content-sessions/the-answer-model) if any
* `company`: Include the associated [company](/api-reference/companies/model) information
* `user`: Include the [user](/api-reference/users/model) who created the session
* You can request multiple expansions: `expand[]=content&expand[]=user`
## Response
Returns a [content session object](/api-reference/content-sessions/model) if found. Returns a 404 Not Found error if the session doesn't exist.
```bash Request theme={null}
# Get a content session with all related data
curl https://api.usertour.io/v1/content-sessions/cmaavyi16003twg0bgimbcdnr?expand[]=content&expand[]=version&expand[]=answers&expand[]=company&expand[]=user \
-H 'Authorization: Bearer ak_123456789' \
-H 'Content-Type: application/json'
```
```json Response theme={null}
{
"id": "cmaavyi16003twg0bgimbcdnr",
"object": "contentSession",
"answers": [],
"completedAt": null,
"completed": false,
"contentId": "cm9f6vwed0002iejc4vg2zu3t",
"content": {
"id": "cm9f6vwed0002iejc4vg2zu3t",
"object": "content",
"name": "fff",
"type": "flow",
"editedVersionId": "cm9mkru2c000mlhmajx3l0146",
"publishedVersionId": "cm9mjsv7d001bvfe8qe8tgzv9",
"updatedAt": "2025-04-18T09:16:37.989Z",
"createdAt": "2025-04-13T05:13:29.797Z"
},
"createdAt": "2025-05-05T09:36:13.002Z",
"companyId": "group-1746437717425",
"company": {
"id": "group-1746437717425",
"object": "company",
"attributes": {
"name": "Iric L"
},
"createdAt": "2025-05-05T09:35:17.434Z"
},
"isPreview": false,
"lastActivityAt": "2025-05-05T09:36:13.016Z",
"progress": 11,
"userId": "user-1746437717384",
"user": {
"id": "user-1746437717384",
"object": "user",
"attributes": {
"like": ["123", "456"],
"male": true,
"name": "Iric L",
"email": "iric-1746437717384@usertour.com",
"sdsdd": 13,
"loginName": "222222222222",
"registerAt": "2024-03-29T16:05:45.000Z",
"website_lead": true
},
"createdAt": "2025-05-05T09:35:17.406Z"
},
"versionId": "cm9mjsv7d001bvfe8qe8tgzv9",
"version": {
"id": "cm9mjsv7d001bvfe8qe8tgzv9",
"object": "contentVersion",
"number": 13,
"updatedAt": "2025-04-18T08:49:26.501Z",
"createdAt": "2025-04-18T08:49:26.501Z"
}
}
```
# List content sessions
Source: https://docs.usertour.io/api-reference/content-sessions/list
Get /v1/content-sessions
Retrieves a paginated list of content sessions with optional filtering and expansion options.
## Query Parameters
Filter sessions by a specific content ID. Only sessions associated with this content will be returned.
Filter sessions by a specific user ID. Only sessions created by this user will be returned.
Specifies the maximum number of items to return in a single response. The value must be between 1 and 100. If not specified, defaults to 20 items per page.
Specifies the starting point for the next page of results. The response will include items that come after (but not including) the object with this ID. To get the next page, use the ID of the last item from your previous request. The easiest way is to use the [list object's](#the-list-object) cursor field. If not provided, the API will return items from the beginning of the list.
Sort the results by specified fields. Available options:
* `createdAt`: Sort by session creation time (default)
* Use `-` prefix for descending order: `orderBy[]=-createdAt`
* You can combine multiple fields: `orderBy[]=-createdAt&orderBy[]=id`
Include additional related objects in the response. Available options:
* `content`: Include the associated [content object](/api-reference/content/model)
* `version`: Include the [content version](/api-reference/content-versions/model) details
* `answers`: Include [survey answers](/api-reference/content-sessions/the-answer-model) if any
* `company`: Include the associated [company](/api-reference/companies/model) information
* `user`: Include the [user](/api-reference/users/model) who created the session
* You can request multiple expansions: `expand[]=content&expand[]=user`
## Response
Returns a [list object](/api-reference/pagination#the-list-object) containing:
* `results`: Array of [content session objects](/api-reference/content-sessions/model)
* `next`: URL for the next page (null if no more pages)
* `previous`: URL for the previous page (null if on first page)
```bash Request theme={null}
# Get the most recent content session with all related data
curl https://api.usertour.io/v1/content-sessions?contentId=cm9f6vwed0002iejc4vg2zu3t&limit=1&expand[]=content&orderBy=-createdAt&expand[]=version&expand[]=answers&expand[]=company&expand[]=user \
-H 'Authorization: Bearer ak_123456789' \
-H 'Content-Type: application/json'
```
```json Response theme={null}
{
"results": [
{
"id": "cmaavyi16003twg0bgimbcdnr",
"object": "contentSession",
"answers": [],
"completedAt": null,
"completed": false,
"contentId": "cm9f6vwed0002iejc4vg2zu3t",
"content": {
"id": "cm9f6vwed0002iejc4vg2zu3t",
"object": "content",
"name": "fff",
"type": "flow",
"editedVersionId": "cm9mkru2c000mlhmajx3l0146",
"publishedVersionId": "cm9mjsv7d001bvfe8qe8tgzv9",
"updatedAt": "2025-04-18T09:16:37.989Z",
"createdAt": "2025-04-13T05:13:29.797Z"
},
"createdAt": "2025-05-05T09:36:13.002Z",
"companyId": "group-1746437717425",
"company": {
"id": "group-1746437717425",
"object": "company",
"attributes": {
"name": "Iric L"
},
"createdAt": "2025-05-05T09:35:17.434Z"
},
"isPreview": false,
"lastActivityAt": "2025-05-05T09:36:13.016Z",
"progress": 11,
"userId": "user-1746437717384",
"user": {
"id": "user-1746437717384",
"object": "user",
"attributes": {
"like": ["123", "456"],
"male": true,
"name": "Iric L",
"email": "iric-1746437717384@usertour.com",
"sdsdd": 13,
"loginName": "222222222222",
"registerAt": "2024-03-29T16:05:45.000Z",
"website_lead": true
},
"createdAt": "2025-05-05T09:35:17.406Z"
},
"versionId": "cm9mjsv7d001bvfe8qe8tgzv9",
"version": {
"id": "cm9mjsv7d001bvfe8qe8tgzv9",
"object": "contentVersion",
"number": 13,
"updatedAt": "2025-04-18T08:49:26.501Z",
"createdAt": "2025-04-18T08:49:26.501Z"
}
}
],
"next": "/v1/content-sessions?contentId=cm9f6vwed0002iejc4vg2zu3t&limit=1&orderBy=-createdAt&cursor=cmaavyi16003twg0bgimbcdnr&expand%5B%5D=content&expand%5B%5D=version&expand%5B%5D=answers&expand%5B%5D=company&expand%5B%5D=user",
"previous": null
}
```
# The content session object
Source: https://docs.usertour.io/api-reference/content-sessions/model
A content session represents a user's interaction with a specific content object (flow, checklist, or launcher). It tracks the user's progress, records their survey responses, and maintains the state of their journey through the content.
A unique identifier for the content session.
The object type identifier. Always set to "contentSession" to distinguish it from other API objects.
A collection of [content session answer objects](/api-reference/content-sessions/the-answer-model) containing the user's responses to survey questions. This field is only populated for flow sessions and can be expanded using the `?expand=answers` query parameter.
The timestamp when the content was completed, in ISO 8601 format (YYYY-MM-DDTHH:mm:ss.sssZ). Returns null if the content is not yet completed.
Indicates whether the content has been completed. For flows, completion occurs when the user reaches a goal step. For checklists, completion means all tasks have been finished.
The identifier of the content object (flow, checklist, or launcher) associated with this session.
The associated [content object](/api-reference/content/model). Can be expanded using the `?expand=content` query parameter.
The timestamp when the session was first created, in ISO 8601 format (YYYY-MM-DDTHH:mm:ss.sssZ).
The identifier of the company associated with this session, if applicable.
The associated [company object](/api-reference/companies/model). Can be expanded using the `?expand=company` query parameter.
Indicates whether this session was initiated by a team member previewing a draft version of the content.
The timestamp of the user's last interaction with the content (e.g., advancing to the next step, expanding a checklist), in ISO 8601 format (YYYY-MM-DDTHH:mm:ss.sssZ).
A decimal value representing the user's progress through the content, where 1.0 indicates 100% completion. For example, 0.4 represents 40% progress.
The identifier of the user who is viewing the content.
The associated [user object](/api-reference/users/model). Can be expanded using the `?expand=user` query parameter.
The identifier of the content version being used in this session.
The associated [content version object](/api-reference/content-versions/model). Can be expanded using the `?expand=version` query parameter.
```json Example content session object theme={null}
{
"id": "cm9l86wtk000zvy5q38v42053",
"object": "contentSession",
"answers": null,
"completedAt": "2025-04-17T10:36:45.933Z",
"completed": true,
"contentId": "cm9f6vwed0002iejc4vg2zu3t",
"content": {
"id": "cm9f6vwed0002iejc4vg2zu3t",
"object": "content",
"name": "fff",
"type": "flow",
"editedVersionId": "cm9mkru2c000mlhmajx3l0146",
"publishedVersionId": "cm9mjsv7d001bvfe8qe8tgzv9",
"updatedAt": "2025-04-18T09:16:37.989Z",
"createdAt": "2025-04-13T05:13:29.797Z"
},
"createdAt": "2025-04-17T10:36:40.232Z",
"companyId": null,
"company": null,
"isPreview": false,
"lastActivityAt": "2025-04-17T10:36:45.933Z",
"progress": 0,
"userId": "1744886199938",
"user": null,
"versionId": "cm9l5txup007lv0gkyphk5l2j",
"version": {
"id": "cm9l5txup007lv0gkyphk5l2j",
"object": "contentVersion",
"number": 4,
"updatedAt": "2025-04-17T09:30:35.794Z",
"createdAt": "2025-04-17T09:30:35.794Z"
}
}
```
# The answer object
Source: https://docs.usertour.io/api-reference/content-sessions/the-answer-model
The content session answer object represents a user's response to a survey question within a content session. It contains both the answer value and metadata about the question being answered.
A unique identifier for the answer.
The object type identifier. Always set to "contentSessionAnswer" to distinguish it from other API objects.
The type of answer. Possible values include:
* "nps" - Net Promoter Score (0-10)
* "star-rating" - Star rating (1-5)
* "multi-line-text" - Multi-line text input
* "multiple-choice" - Multiple choice selection
* "single-line-text" - Single line text input
* "scale" - Scale rating (1-10)
The value of the answer. The format depends on the answerType:
* For "nps", "star-rating", "scale": A number as a string (e.g., "2", "8")
* For "multiple-choice": The selected choice option value
* For "single-line-text", "multi-line-text": The text input value
The timestamp when the answer was provided, in ISO 8601 format (YYYY-MM-DDTHH:mm:ss.sssZ).
The Cross-Version ID (CVID) of the question object that this answer corresponds to.
The name of the question object that this answer corresponds to.
```json Example content session answer object theme={null}
{
"id": "cm9md7s3s00enurz50zx8ovad",
"object": "contentSessionAnswer",
"answerType": "nps",
"answerValue": "8",
"createdAt": "2025-04-18T05:45:05.028Z",
"questionCvid": "llwjbkwqb7xufu2okq8gk7ud",
"questionName": "How likely are you to recommend our service?"
}
```
# Create a content version
Source: https://docs.usertour.io/api-reference/content-versions/create-a-content-version
/api-reference-v2/openapi.json post /v2/projects/{projectId}/content/{contentId}/versions
Ensure an editable draft: returns the current edited version while it is an unpublished draft; forks it only when it is published (locked). A fork returns the slim version envelope — the copied steps/data are not inlined; read the version with `expand` to inspect them.
# Get a content version
Source: https://docs.usertour.io/api-reference/content-versions/get
Get /v1/content-versions/:id
Retrieves a specific version of a content object by its version ID.
## Path parameters
The unique identifier of the content version to retrieve.
## Query parameters
See [Expanding objects](/api-reference/expanding-objects). Available options:
* `questions`: Include all questions associated with this version (only applicable for flow content types)
## Response
Returns a [content version object](/api-reference/content-versions/model) if found. Returns a 404 Not Found error if the version doesn't exist.
```bash Request theme={null}
# Get a content version with its questions
curl https://api.usertour.io/v1/content-versions/cm9l2ws8o004yv0gk0r01r1ti?expand=questions \
-H 'Authorization: Bearer ak_123456789' \
-H 'Content-Type: application/json'
```
```json Response theme={null}
{
"id": "cm9l2ws8o004yv0gk0r01r1ti",
"object": "contentVersion",
"number": 3,
"questions": [
{
"object": "question",
"cvid": "llwjbkwqb7xufu2okq8gk7ud",
"name": "How likely are you to recommend our service?",
"type": "nps"
},
{
"object": "question",
"cvid": "eforyzobzloaxa439fg6w1vw",
"name": "What aspects could we improve?",
"type": "nps"
},
{
"object": "question",
"cvid": "k65k0h7clxsd32x1t9ocnrbt",
"name": "Additional feedback",
"type": "multi-line-text"
}
],
"updatedAt": "2025-04-17T09:28:33.999Z",
"createdAt": "2025-04-17T08:08:49.637Z"
}
```
# Get a content version
Source: https://docs.usertour.io/api-reference/content-versions/get-a-content-version
/api-reference-v2/openapi.json get /v2/projects/{projectId}/content/{contentId}/versions/{id}
# List content versions
Source: https://docs.usertour.io/api-reference/content-versions/list
Get /v1/content-versions
Retrieves a paginated list of versions for a specific content object.
## Query parameters
The ID of the content object whose versions you want to list. This parameter is required as versions can only be listed for a specific content object.
Specifies the maximum number of items to return in a single response. The value must be between 1 and 100. If not specified, defaults to 20 items per page.
Specifies the starting point for the next page of results. The response will include items that come after (but not including) the object with this ID. To get the next page, use the ID of the last item from your previous request. The easiest way is to use the [list object's](#the-list-object) cursor field. If not provided, the API will return items from the beginning of the list.
See [Ordering](/api-reference/ordering). Available fields:
* `createdAt`: Sort by creation date (default)
* You can use multiple fields and specify sort direction: `orderBy[]=-createdAt` for descending order
See [Expanding objects](/api-reference/expanding-objects). Available options:
* `questions`: Include all questions associated with each version (only applicable for flow content types)
## Response
Returns a [list object](/api-reference/pagination#the-list-object) containing:
* `results`: Array of [content version objects](/api-reference/content-versions/model)
* `next`: URL for the next page of results (null if no more pages)
* `previous`: URL for the previous page of results (null if on first page)
```bash Request theme={null}
# Get the first page of versions for a specific content
curl https://api.usertour.io/v1/content-versions?contentId=cm9f6vwed0002iejc4vg2zu3t&limit=2&expand=questions \
-H 'Authorization: Bearer ak_123456789' \
-H 'Content-Type: application/json'
```
```json Response theme={null}
{
"results": [
{
"id": "cm9f6vwei0004iejcb9uznra3",
"object": "contentVersion",
"number": 0,
"questions": [],
"updatedAt": "2025-04-13T05:13:47.373Z",
"createdAt": "2025-04-13T05:13:29.797Z"
},
{
"id": "cm9l2ws8o004yv0gk0r01r1ti",
"object": "contentVersion",
"number": 3,
"questions": [
{
"object": "question",
"cvid": "llwjbkwqb7xufu2okq8gk7ud",
"name": "How likely are you to recommend our service?",
"type": "nps"
},
{
"object": "question",
"cvid": "eforyzobzloaxa439fg6w1vw",
"name": "What aspects could we improve?",
"type": "nps"
},
{
"object": "question",
"cvid": "k65k0h7clxsd32x1t9ocnrbt",
"name": "Additional feedback",
"type": "multi-line-text"
}
],
"updatedAt": "2025-04-17T09:28:33.999Z",
"createdAt": "2025-04-17T08:08:49.637Z"
}
],
"next": "/v1/content-versions?contentId=cm9f6vwed0002iejc4vg2zu3t&limit=2&expand=questions&cursor=cm9l2ws8o004yv0gk0r01r1ti",
"previous": null
}
```
# List content versions
Source: https://docs.usertour.io/api-reference/content-versions/list-content-versions
/api-reference-v2/openapi.json get /v2/projects/{projectId}/content/{contentId}/versions
# The content version object
Source: https://docs.usertour.io/api-reference/content-versions/model
Content versions represent different iterations of your content (flows, checklists, and launchers). A new version is automatically created when you make edits or publish content, allowing you to track changes and maintain a history of your content's evolution.
A unique identifier for the content version.
The object type identifier. Always set to "contentVersion" to distinguish it from other API objects.
The version number of the content. Version numbers are incremental for each content object, starting from 1.
A collection of [question objects](/api-reference/content-versions/the-question-model) associated with this version. This field is only populated for flow content types and can be expanded using the `?expand=questions` query parameter.
The timestamp when the version was last updated, in ISO 8601 format (YYYY-MM-DDTHH:mm:ss.sssZ).
The timestamp when the version was created, in ISO 8601 format (YYYY-MM-DDTHH:mm:ss.sssZ).
```json Example content version object theme={null}
{
"id": "cm9ja53e9005lrjlxm3zo13g8",
"object": "contentVersion",
"number": 1,
"questions": [
{
"object": "question",
"cvid": "llwjbkwqb7xufu2okq8gk7ud",
"name": "How likely are you to recommend our service?",
"type": "nps"
},
{
"object": "question",
"cvid": "e79iosim51pbvy4ikq9igh5a",
"name": "Rate your experience",
"type": "star-rating"
}
],
"updatedAt": "2025-04-16T01:55:42.307Z",
"createdAt": "2025-04-16T01:55:42.307Z"
}
```
# Restore a content version
Source: https://docs.usertour.io/api-reference/content-versions/restore-a-content-version
/api-reference-v2/openapi.json post /v2/projects/{projectId}/content/{contentId}/versions/{id}/restore
Fork a historical version forward as the new draft.
# The question object
Source: https://docs.usertour.io/api-reference/content-versions/the-question-model
The question object represents a survey question within a content version. It defines the structure and type of the question, along with its metadata.
A unique identifier for the question. Important: The ID of questions changes between versions. When a new version is created, all components are copied with new ID values. Use `cvid` for stable cross-version identification.
The object type identifier. Always set to "question" to distinguish it from other API objects.
Cross-Version ID (CVID). A stable identifier that remains constant for the same logical question across different versions. Use this instead of `id` when referencing questions, as it persists across version changes.
The name of the question as defined in the Builder interface.
The type of question. Supported values:
* "nps" - Net Promoter Score (0-10)
* "star-rating" - Star rating (1-5)
* "multi-line-text" - Multi-line text input
* "multiple-choice" - Multiple choice selection
* "single-line-text" - Single line text input
* "scale" - Scale rating (1-10)
The timestamp when the question was last updated, in ISO 8601 format (YYYY-MM-DDTHH:mm:ss.sssZ).
The timestamp when the question was created, in ISO 8601 format (YYYY-MM-DDTHH:mm:ss.sssZ).
```json Example question object theme={null}
{
"id": "cm9f6vwed0002iejc4vg2zu3t",
"object": "question",
"cvid": "llwjbkwqb7xufu2okq8gk7ud",
"name": "How likely are you to recommend our service?",
"type": "nps",
"updatedAt": "2025-04-18T09:16:37.989Z",
"createdAt": "2025-04-13T05:13:29.797Z"
}
```
# Update a content version
Source: https://docs.usertour.io/api-reference/content-versions/update-a-content-version
/api-reference-v2/openapi.json patch /v2/projects/{projectId}/content/{contentId}/versions/{id}
Write steps, start/hide rules, themeId, or type-specific data to a draft version.
# Validate a content version
Source: https://docs.usertour.io/api-reference/content-versions/validate-a-content-version
/api-reference-v2/openapi.json get /v2/projects/{projectId}/content/{contentId}/versions/{id}/validate
Dry-run usability check: the same rules `publish` enforces, without mutating. Returns errors (these block publish) and advisory warnings.
# Create content
Source: https://docs.usertour.io/api-reference/content/create-content
/api-reference-v2/openapi.json post /v2/projects/{projectId}/content
# Delete content
Source: https://docs.usertour.io/api-reference/content/delete-content
/api-reference-v2/openapi.json delete /v2/projects/{projectId}/content/{id}
Soft delete (recoverable via POST /content/{id}/restore). Deleting also UNPUBLISHES the content from every environment and permanently discards the per-environment publish state (which environments, which version, since when) — restore returns an UNPUBLISHED draft, not that history. "Was this ever live?" is answerable afterwards through the publish history (available through the MCP) or the app's audit log.
# Duplicate content
Source: https://docs.usertour.io/api-reference/content/duplicate-content
/api-reference-v2/openapi.json post /v2/projects/{projectId}/content/{id}/duplicate
Duplicate into a new content (copies the edited version's steps / config / data).
# Get a content
Source: https://docs.usertour.io/api-reference/content/get
Get /v1/content/:id
Retrieves a single content object by its ID.
## Path parameters
The unique identifier of the content object you want to retrieve.
## Query parameters
Specify which related objects to include in the response. Available options:
* `editedVersion`: Include the current draft version being edited
* `publishedVersion`: Include the currently published version
* You can request multiple expansions using array syntax: `expand[]=publishedVersion&expand[]=editedVersion`
## Response
Returns a [content object](/api-reference/content/model) if found. Returns a 404 Not Found error if the content doesn't exist.
```bash Request theme={null}
# Get a content with both draft and published versions
curl https://api.usertour.io/v1/content/cm9f6vwed0002iejc4vg2zu3t?expand[]=publishedVersion&expand[]=editedVersion \
-H 'Authorization: Bearer ak_123456789' \
-H 'Content-Type: application/json'
```
```json Response theme={null}
{
"id": "cm9f6vwed0002iejc4vg2zu3t",
"object": "content",
"name": "Product Onboarding Flow",
"type": "flow",
"editedVersionId": "cm9mkru2c000mlhmajx3l0146",
"editedVersion": {
"id": "cm9mkru2c000mlhmajx3l0146",
"object": "contentVersion",
"number": 14,
"questions": [],
"updatedAt": "2025-04-18T09:16:37.989Z",
"createdAt": "2025-04-18T09:16:37.989Z"
},
"publishedVersionId": "cm9mjsv7d001bvfe8qe8tgzv9",
"publishedVersion": {
"id": "cm9mjsv7d001bvfe8qe8tgzv9",
"object": "contentVersion",
"number": 13,
"questions": [],
"updatedAt": "2025-04-18T08:49:26.501Z",
"createdAt": "2025-04-18T08:49:26.501Z"
},
"updatedAt": "2025-04-18T09:16:37.989Z",
"createdAt": "2025-04-13T05:13:29.797Z"
}
```
# Get content
Source: https://docs.usertour.io/api-reference/content/get-content
/api-reference-v2/openapi.json get /v2/projects/{projectId}/content/{id}
# List content
Source: https://docs.usertour.io/api-reference/content/list
Get /v1/content
Retrieves a paginated list of all content objects in your account.
## Query parameters
Specifies the maximum number of items to return in a single response. The value must be between 1 and 100. If not specified, defaults to 20 items per page.
Specifies the starting point for the next page of results. The response will include items that come after (but not including) the object with this ID. To get the next page, use the ID of the last item from your previous request. The easiest way is to use the [list object's](#the-list-object) cursor field. If not provided, the API will return items from the beginning of the list.
See [Ordering](/api-reference/ordering). Available fields:
* `createdAt`: Sort by creation date (default)
* You can use multiple fields and specify sort direction: `orderBy[]=-createdAt` for descending order
See [Expanding objects](/api-reference/expanding-objects). Available options:
* `editedVersion`: Include the current draft version being edited
* `publishedVersion`: Include the currently published version
* You can request multiple expansions: `expand[]=publishedVersion&expand[]=editedVersion`
## Response
Returns a [list object](/api-reference/pagination#the-list-object) containing:
* `results`: Array of [content objects](/api-reference/content/model)
* `next`: URL for the next page of results (null if no more pages)
* `previous`: URL for the previous page of results (null if on first page)
```bash Request theme={null}
# Get the first page of content objects
curl https://api.usertour.io/v1/content \
-H 'Authorization: Bearer ak_123456789' \
-H 'Content-Type: application/json'
```
```json Response theme={null}
{
"results": [
{
"id": "cm9f6vwed0002iejc4vg2zu3t",
"object": "content",
"name": "Product Onboarding Flow",
"type": "flow",
"editedVersionId": "cm9mkru2c000mlhmajx3l0146",
"publishedVersionId": "cm9mjsv7d001bvfe8qe8tgzv9",
"updatedAt": "2025-04-18T09:16:37.989Z",
"createdAt": "2025-04-13T05:13:29.797Z"
},
{
"id": "cm9jaftye007yrjlx3ghggigi",
"object": "content",
"name": "User Setup Checklist",
"type": "checklist",
"editedVersionId": "cm9jaftyh0080rjlx8j2p50qn",
"publishedVersionId": "cm9jaftyh0080rjlx8j2p50qn",
"updatedAt": "2025-04-16T02:04:16.476Z",
"createdAt": "2025-04-16T02:04:03.300Z"
}
],
"next": "/v1/content?limit=2&cursor=cm9jaftye007yrjlx3ghggigi",
"previous": null
}
```
# List content
Source: https://docs.usertour.io/api-reference/content/list-content
/api-reference-v2/openapi.json get /v2/projects/{projectId}/content
# The content object
Source: https://docs.usertour.io/api-reference/content/model
The content object represents a container for different types of interactive content (flows, checklists, and launchers). It manages the versioning of your content, tracking both the draft version being edited and the published version currently in use.
A unique identifier for the content object.
The object type identifier. Always set to "content" to distinguish it from other API objects.
The name of the content as defined in the Builder interface.
The type of content. Supported values:
* "flow" - Interactive step-by-step guides
* "checklist" - Task-based checklists
* "launcher" - Trigger-based content launchers
The identifier of the current draft version being edited in the Builder.
The associated [content version object](/api-reference/content-versions/model) for the draft version. Can be expanded using the `?expand=draft_version` query parameter.
The identifier of the currently published version in the environment.
The associated [content version object](/api-reference/content-versions/model) for the published version. Can be expanded using the `?expand=published_version` query parameter.
The timestamp when the content was last updated, in ISO 8601 format (YYYY-MM-DDTHH:mm:ss.sssZ).
The timestamp when the content was created, in ISO 8601 format (YYYY-MM-DDTHH:mm:ss.sssZ).
```json Example content object theme={null}
{
"id": "cm9f6vwed0002iejc4vg2zu3t",
"object": "content",
"name": "Product Onboarding Flow",
"type": "flow",
"editedVersionId": "cm9mkru2c000mlhmajx3l0146",
"editedVersion": {
"id": "cm9mkru2c000mlhmajx3l0146",
"object": "contentVersion",
"number": 14,
"questions": [],
"updatedAt": "2025-04-18T09:16:37.989Z",
"createdAt": "2025-04-18T09:16:37.989Z"
},
"publishedVersionId": "cm9mjsv7d001bvfe8qe8tgzv9",
"publishedVersion": {
"id": "cm9mjsv7d001bvfe8qe8tgzv9",
"object": "contentVersion",
"number": 13,
"questions": [],
"updatedAt": "2025-04-18T08:49:26.501Z",
"createdAt": "2025-04-18T08:49:26.501Z"
},
"updatedAt": "2025-04-18T09:16:37.989Z",
"createdAt": "2025-04-13T05:13:29.797Z"
}
```
# Publish a version
Source: https://docs.usertour.io/api-reference/content/publish-a-version
/api-reference-v2/openapi.json post /v2/projects/{projectId}/content/{id}/publish
Set a version as the live version in an environment (idempotent).
# Restore deleted content
Source: https://docs.usertour.io/api-reference/content/restore-deleted-content
/api-reference-v2/openapi.json post /v2/projects/{projectId}/content/{id}/restore
Restore soft-deleted content (find it via GET /content?deleted=true). It returns as an unpublished draft with versions and history intact — publish again explicitly to go live. Idempotent on content that is not deleted.
# Unpublish a version
Source: https://docs.usertour.io/api-reference/content/unpublish-a-version
/api-reference-v2/openapi.json post /v2/projects/{projectId}/content/{id}/unpublish
Clear an environment's live version for the content.
# Update content
Source: https://docs.usertour.io/api-reference/content/update-content
/api-reference-v2/openapi.json patch /v2/projects/{projectId}/content/{id}
Update content metadata (name, buildUrl).
# Create an environment
Source: https://docs.usertour.io/api-reference/environments/create-an-environment
/api-reference-v2/openapi.json post /v2/projects/{projectId}/environments
Create an environment in the project. The first one is made primary.
# Delete an environment
Source: https://docs.usertour.io/api-reference/environments/delete-an-environment
/api-reference-v2/openapi.json delete /v2/projects/{projectId}/environments/{id}
Delete an environment. The primary / last environment cannot be deleted.
# Get an environment
Source: https://docs.usertour.io/api-reference/environments/get-an-environment
/api-reference-v2/openapi.json get /v2/projects/{projectId}/environments/{id}
# List environments
Source: https://docs.usertour.io/api-reference/environments/list-environments
/api-reference-v2/openapi.json get /v2/projects/{projectId}/environments
# Update an environment
Source: https://docs.usertour.io/api-reference/environments/update-an-environment
/api-reference-v2/openapi.json patch /v2/projects/{projectId}/environments/{id}
Rename an environment.
# Errors
Source: https://docs.usertour.io/api-reference/errors
Troubleshoot problems with this comprehensive breakdown of all error codes.
## Error schema
We use standard HTTP response codes for success and failure notifications, and our errors are further classified by type.
## Error responses
Error responses contains an `error` object with the following properties:
Machine-readable error code. This is the same as the error code shown in the error documentation.
Human-readable explanation of what went wrong. This message is designed to be helpful for developers.
URL to the Usertour API docs. This link will take you to the relevant documentation for the error.
**Example error response:**
```json theme={null}
{
"error": {
"code": "E1017",
"message": "each value in orderBy must be one of the following values: createdAt, -createdAt, codeName, -codeName, displayName, -displayName",
"doc_url": "https://docs.usertour.com"
}
}
```
## Error codes
### `E1000`
* **Status:** 403
* **Message:** Invalid API key provided
* **Suggested action:** Check your API key and make sure it's correct.
### `E1010`
* **Status:** 401
* **Message:** Missing API key
* **Suggested action:** Add your API key to the request headers.
### `E1001`
* **Status:** 404
* **Message:** User not found
* **Suggested action:** Check the user ID and make sure it exists.
### `E1002`
* **Status:** 404
* **Message:** Company not found
* **Suggested action:** Check the company ID and make sure it exists.
### `E1003`
* **Status:** 404
* **Message:** Company membership not found
* **Suggested action:** Check the company membership ID and make sure it exists.
### `E1004`
* **Status:** 404
* **Message:** Content not found
* **Suggested action:** Check the content ID and make sure it exists.
### `E1005`
* **Status:** 404
* **Message:** Content session not found
* **Suggested action:** Check the content session ID and make sure it exists.
### `E1006`
* **Status:** 400
* **Message:** Invalid limit parameter
* **Suggested action:** Check the limit value and make sure it's valid.
### `E1007`
* **Status:** 400
* **Message:** Invalid cursor parameter
* **Suggested action:** Check the cursor value and make sure it's valid.
### `E1008`
* **Status:** 400
* **Message:** Invalid previous cursor parameter
* **Suggested action:** Check the previous cursor value and make sure it's valid.
### `E1009`
* **Status:** 400
* **Message:** Invalid request
* **Suggested action:** Check your request parameters and make sure they're valid.
### `E1013`
* **Status:** 429
* **Message:** Too many requests
* **Suggested action:** Reduce your request rate or upgrade your plan.
### `E1014`
* **Status:** 503
* **Message:** Service unavailable
* **Suggested action:** Try again later or contact support if the problem persists.
### `E1015`
* **Status:** 400
* **Message:** Invalid scope parameter
* **Suggested action:** Check the scope value and make sure it's valid.
### `E1016`
* **Status:** 400
* **Message:** Invalid orderBy parameter
* **Suggested action:** Check the orderBy value and make sure it's valid.
### `E1017`
* **Status:** 400
* **Message:** Validation error
* **Suggested action:** Check the value and make sure it's valid.
# Create an event definition
Source: https://docs.usertour.io/api-reference/event-definitions/create-an-event-definition
/api-reference-v2/openapi.json post /v2/projects/{projectId}/event-definitions
# Delete an event definition
Source: https://docs.usertour.io/api-reference/event-definitions/delete-an-event-definition
/api-reference-v2/openapi.json delete /v2/projects/{projectId}/event-definitions/{id}
# Get an event definition
Source: https://docs.usertour.io/api-reference/event-definitions/get-an-event-definition
/api-reference-v2/openapi.json get /v2/projects/{projectId}/event-definitions/{id}
# List event definitions
Source: https://docs.usertour.io/api-reference/event-definitions/list
Get /v1/event-definitions
Retrieves a paginated list of event definitions in your account. Each definition includes its name, code, and metadata.
This endpoint returns a paginated list of event definitions. Each definition contains its display name, code name, and creation timestamp.
## Query parameters
Sort the results by one or more fields. Available fields:
* `createdAt`: Creation timestamp
* `codeName`: Technical name
* `displayName`: Display name
Examples:
* Single field: `orderBy=createdAt` or `orderBy=-createdAt` (descending)
* Multiple fields: `orderBy[]=-createdAt&orderBy[]=displayName`
Specifies the maximum number of items to return in a single response. The value must be between 1 and 100. If not specified, defaults to 20 items per page.
Specifies the starting point for the next page of results. The response will include items that come after (but not including) the object with this ID. To get the next page, use the ID of the last item from your previous request. The easiest way is to use the [list object's](#the-list-object) cursor field. If not provided, the API will return items from the beginning of the list.
## Response
Returns a [list object](/api-reference/pagination#the-list-object) containing an array of [event definition objects](/api-reference/event-definitions/model) in the `results` property. The response includes pagination information in the `next` and `previous` fields.
```bash Request theme={null}
# List event definitions with pagination
curl https://api.usertour.io/v1/event-definitions?limit=4&orderBy=createdAt \
-H 'Authorization: Bearer ak_123456789' \
-H 'Content-Type: application/json'
```
```json Response theme={null}
{
"results": [
{
"id": "cm9e922p3001pmjao5vba8054",
"object": "eventDefinition",
"createdAt": "2025-04-12T13:26:30.952Z",
"description": "",
"displayName": "Flow Started",
"codeName": "flow_started"
},
{
"id": "cm9e922p3001qmjaozg69mzk9",
"object": "eventDefinition",
"createdAt": "2025-04-12T13:26:30.952Z",
"description": "",
"displayName": "Flow Dismissed/Ended",
"codeName": "flow_ended"
},
{
"id": "cm9e922p3001rmjaom1h20luk",
"object": "eventDefinition",
"createdAt": "2025-04-12T13:26:30.952Z",
"description": "",
"displayName": "Flow Step Seen",
"codeName": "flow_step_seen"
},
{
"id": "cm9e922p4001smjaov92o3zq8",
"object": "eventDefinition",
"createdAt": "2025-04-12T13:26:30.952Z",
"description": "",
"displayName": "Flow Step Completed",
"codeName": "flow_step_completed"
}
],
"next": "/v1/event-definitions?limit=4&orderBy=createdAt&cursor=cm9e922p4001smjaov92o3zq8",
"previous": null
}
```
# List event definitions
Source: https://docs.usertour.io/api-reference/event-definitions/list-event-definitions
/api-reference-v2/openapi.json get /v2/projects/{projectId}/event-definitions
# The event definition object
Source: https://docs.usertour.io/api-reference/event-definitions/model
Event definitions are metadata objects that describe the structure and purpose of events in your application. They are automatically created when you track new events through the Usertour API or SDK.
These definitions serve as a schema registry for your events, ensuring consistency in event tracking across your application. While you can't directly manage event definitions through the API, they are automatically maintained as you track events.
A unique identifier for the event definition.
The object type identifier. Always set to "eventDefinition" to distinguish it from other API objects.
The timestamp when the event definition was first created, in ISO 8601 format (YYYY-MM-DDTHH:mm:ss.sssZ).
A technical description of the event's purpose and context. This field is editable in the Usertour Dashboard and helps maintain documentation of your event tracking implementation.
The human-readable identifier for the event, used in the Usertour Dashboard and analytics interfaces. This can be modified to better reflect your event naming conventions.
The immutable identifier used in your code when tracking events. This value is used in the `name` field of event objects and cannot be changed after creation to maintain data consistency.
```json Example event definition object theme={null}
{
"id": "cm9e922p3001pmjao5vba8054",
"object": "eventDefinition",
"createdAt": "2025-04-12T13:26:30.952Z",
"description": "Triggered when a user starts a flow",
"displayName": "Flow Started",
"codeName": "flow_started"
}
```
# Update an event definition
Source: https://docs.usertour.io/api-reference/event-definitions/update-an-event-definition
/api-reference-v2/openapi.json patch /v2/projects/{projectId}/event-definitions/{id}
# Track an event
Source: https://docs.usertour.io/api-reference/events/track-an-event
/api-reference-v2/openapi.json post /v2/projects/{projectId}/environments/{environmentId}/events
Records a behavior event for a user. Unseen users are created; an unknown event name registers a definition on first use; built-in Usertour event names are refused.
# Expanding objects
Source: https://docs.usertour.io/api-reference/expanding-objects
Learn how to expand related objects in API responses using the ```expand``` query parameter.
## Overview
The API supports expanding related objects in responses using the `expand` query parameter. This allows you to retrieve complete objects instead of just their IDs. For example, when fetching an event, you can expand the associated user object to get the full user details instead of just the `userId`.
## Basic Usage
### Single Object Expansion
For one-to-one relationships, use the `expand` parameter to include the full object. For example, adding `?expand=user` to a request will include the complete user object in the response.
### List Expansion
For one-to-many relationships, the API will return an array of expanded objects. By default, these fields are `null` (not expanded). For example, adding `?expand=memberships` will include an array of all the user's memberships.
## Advanced Usage
### Nested Expansion
You can expand nested relationships by using dot notation (`.`). For example, `?expand=memberships.company` will expand both the memberships and their associated companies.
### Multiple Expansions
To expand multiple objects simultaneously, use the array syntax with multiple paths:
```bash theme={null}
?expand[]=user&expand[]=company
```
## Supported Endpoints
The `expand` parameter can be used on any endpoint that returns expandable fields, including:
* List endpoints
* Create endpoints
* Update endpoints
## Example
### Request
```bash theme={null}
curl https://api.usertour.io/v1/users/1744521292871a?expand[]=memberships&expand[]=companies&expand[]=memberships.company \
-H 'Authorization: Bearer '
```
### Response
```json theme={null}
{
"id": "1744521292871a",
"object": "user",
"attributes": {},
"createdAt": "2025-04-27T13:39:47.024Z",
"companies": [
{
"id": "1744521292871a",
"object": "company",
"attributes": {},
"createdAt": "2025-04-27T13:39:47.024Z"
}
],
"memberships": [
{
"id": "cma9nhdis0002108kpmr9vfrl",
"object": "companyMembership",
"attributes": {
"role": "admin"
},
"createdAt": "2025-05-04T12:51:10.881Z",
"companyId": "cm9zp4wyh00116pt4o5v5vg5t",
"userId": "cm9zp4wyb000z6pt484wnbzs7",
"company": {
"id": "1744521292871a",
"object": "company",
"attributes": {},
"createdAt": "2025-04-27T13:39:47.024Z"
}
}
]
}
```
# Introduction
Source: https://docs.usertour.io/api-reference/introduction
Understand general concepts, response codes, and authentication strategies.
This is the **legacy v1 API**. New integrations should use the
[v2 API](/api-reference-v2/introduction) — it adds content authoring, publishing,
and an MCP endpoint. v1 remains available for existing integrations.
## Base URL
The Usertour API is built on **REST** principles. We enforce **HTTPS** in every request to improve data security, integrity, and privacy. The API does not support **HTTP**.
All requests contain the following base URL:
```
https://api.usertour.io
```
## Authentication
To authenticate you need to add an Authorization header with the contents of the header being `Bearer ak_123456789` where `ak_123456789` is your [API Key](https://app.usertour.io/project/1/settings/api).
```
Authorization: Bearer ak_123456789
```
## Response codes
Usertour uses standard HTTP codes to indicate the success or failure of your requests.
In general, `2xx` HTTP codes correspond to success, `4xx` codes are for user-related failures, and `5xx` codes are for infrastructure issues.
| Status | Description |
| ------ | ----------------------------------------- |
| `200` | Successful request. |
| `400` | Check that the parameters were correct. |
| `401` | The API key used was missing. |
| `403` | The API key used was invalid. |
| `404` | The resource was not found. |
| `429` | The rate limit was exceeded. |
| `5xx` | Indicates an error with Usertour servers. |
Check [**Error Codes**](/api-reference/errors) for a comprehensive breakdown of all possible API errors.
## Rate limit
The API implements two protection mechanisms to ensure service stability:
1. **Request Rate Limiter**
* Enforces per-account request limits per minute
* Rate limits vary based on your subscription plan (see [Pricing](https://www.usertour.io/pricing))
* Exceeding the limit returns HTTP 429 (Too Many Requests)
2. **Concurrency Limiter**
* Controls simultaneous active requests
* Implements request queuing when at high capacity
* Queue timeout: 15 seconds
* Returns HTTP 503 (Service Unavailable) if queued too long
* Note: This is a rare occurrence as we maintain sufficient capacity to minimize queuing
For optimal integration, implement proper error handling for both 429 and 503 responses. Consider implementing retry logic with exponential backoff when receiving these status codes.
# Introspect the token
Source: https://docs.usertour.io/api-reference/me/introspect-the-token
/api-reference-v2/openapi.json get /v2/me
Returns the token's name and the projects and environments it may act on — the credential-test and picker-population call for integration platforms.
# Ordering
Source: https://docs.usertour.io/api-reference/ordering
Some top-level API resources that support listing can be ordered by one or more fields using the orderBy query parameter. Each resource will list which fields it can be ordered by.
## Query parameters
Specifies the field(s) to sort the results by. You can provide either a single field or an array of fields. When multiple fields are specified, the results are sorted by the first field first, then by the second field, and so on. By default, fields are sorted in ascending order. To sort in descending order, prefix the field name with a hyphen (-).
## Examples
### Order by a single field
```bash theme={null}
GET /users?orderBy=name
```
This will sort the results by the name field in ascending order.
### Order by multiple fields
```bash theme={null}
GET /users?orderBy[]=attributes.name&orderBy[]=createdAt
```
This will first sort by the name attribute, and then by the creation date, both in ascending order.
### Use descending order
```bash theme={null}
GET /users?orderBy=-createdAt
```
This will sort the results by the creation date in descending order (newest first).
# Pagination
Source: https://docs.usertour.io/api-reference/pagination
All top-level API resources that support listing use the same format for pagination and the same response object.
## Query parameters
Specifies the maximum number of items to return in a single response. The value must be between 1 and 100. If not specified, defaults to 20 items per page.
Specifies the starting point for the next page of results. The response will include items that come after (but not including) the object with this ID. To get the next page, use the ID of the last item from your previous request. The easiest way is to use the [list object's](#the-list-object) cursor field. If not provided, the API will return items from the beginning of the list.
## The list object
All list endpoints return a standardized response object with the following structure:
An array containing the requested resource objects (e.g., users, companies). The number of items will not exceed the specified limit.
The URL to fetch the next page of results. This field is always present, as it allows clients to know how to fetch the next page if new items become available later.
The URL to fetch the previous page of results. This field is null when you're on the first page or when there is no previous page available.
```json theme={null}
{
"results": [
{
"id": "1744521292871",
"object": "user",
...
},
{
"id": "1744521298100",
"object": "user",
...
}
...
],
"next": "/v1/users?limit=2&cursor=cm9f6xsjr0014iejcuc80dy3q",
"previous": null
}
```
# Add a segment member
Source: https://docs.usertour.io/api-reference/segments/add-a-segment-member
/api-reference-v2/openapi.json put /v2/projects/{projectId}/environments/{environmentId}/segments/{id}/members/{externalId}
Add a user or company (per the segment's bizType) to a manual segment.
# Create a segment
Source: https://docs.usertour.io/api-reference/segments/create-a-segment
/api-reference-v2/openapi.json post /v2/projects/{projectId}/segments
# Delete a segment
Source: https://docs.usertour.io/api-reference/segments/delete-a-segment
/api-reference-v2/openapi.json delete /v2/projects/{projectId}/segments/{id}
# Get a segment
Source: https://docs.usertour.io/api-reference/segments/get-a-segment
/api-reference-v2/openapi.json get /v2/projects/{projectId}/segments/{id}
# List segments
Source: https://docs.usertour.io/api-reference/segments/list-segments
/api-reference-v2/openapi.json get /v2/projects/{projectId}/segments
# Remove a segment member
Source: https://docs.usertour.io/api-reference/segments/remove-a-segment-member
/api-reference-v2/openapi.json delete /v2/projects/{projectId}/environments/{environmentId}/segments/{id}/members/{externalId}
Remove a user or company (per the segment's bizType) from a manual segment.
# Update a segment
Source: https://docs.usertour.io/api-reference/segments/update-a-segment
/api-reference-v2/openapi.json patch /v2/projects/{projectId}/segments/{id}
# Delete a session
Source: https://docs.usertour.io/api-reference/sessions/delete-a-session
/api-reference-v2/openapi.json delete /v2/projects/{projectId}/environments/{environmentId}/sessions/{id}
PERMANENT, with three distinct consequences: the session record is gone (no restore exists); its recorded answers vanish from question analytics irreversibly — deleting "test" sessions rewrites real response counts; and attribute values the session wrote onto the user via bindAttribute REMAIN on the user (clearing those is a separate user-attribute update, or the profile will contradict the survey analytics).
# End a session
Source: https://docs.usertour.io/api-reference/sessions/end-a-session
/api-reference-v2/openapi.json post /v2/projects/{projectId}/environments/{environmentId}/sessions/{id}/end
End an in-progress session. Idempotent: a session already in its terminal state is returned as-is. Tracker sessions have no end semantics and refuse with E1017.
# Get a session
Source: https://docs.usertour.io/api-reference/sessions/get-a-session
/api-reference-v2/openapi.json get /v2/projects/{projectId}/environments/{environmentId}/sessions/{id}
# List sessions
Source: https://docs.usertour.io/api-reference/sessions/list-sessions
/api-reference-v2/openapi.json get /v2/projects/{projectId}/environments/{environmentId}/sessions
Sessions in this environment. Filter by contentId / userId.
# Create a theme
Source: https://docs.usertour.io/api-reference/themes/create-a-theme
/api-reference-v2/openapi.json post /v2/projects/{projectId}/themes
# Delete a theme
Source: https://docs.usertour.io/api-reference/themes/delete-a-theme
/api-reference-v2/openapi.json delete /v2/projects/{projectId}/themes/{id}
Rejected for the default / system theme, and while any live or draft version still uses the theme (409 E1031 — switch that content to another theme first). Historical versions do not block deletion.
# Duplicate a theme
Source: https://docs.usertour.io/api-reference/themes/duplicate-a-theme
/api-reference-v2/openapi.json post /v2/projects/{projectId}/themes/{id}/duplicate
Copy a theme (settings + variations verbatim) into a fresh non-default theme. System themes may be duplicated (the natural "derive from Standard Light" flow).
# Get a theme
Source: https://docs.usertour.io/api-reference/themes/get-a-theme
/api-reference-v2/openapi.json get /v2/projects/{projectId}/themes/{id}
# List themes
Source: https://docs.usertour.io/api-reference/themes/list-themes
/api-reference-v2/openapi.json get /v2/projects/{projectId}/themes
# Update a theme
Source: https://docs.usertour.io/api-reference/themes/update-a-theme
/api-reference-v2/openapi.json patch /v2/projects/{projectId}/themes/{id}
# Upsert a user
Source: https://docs.usertour.io/api-reference/users/create
Post /v1/users
This endpoint allows you to create a new user or update an existing one. The operation is idempotent - if a user with the specified ID already exists, the request will update the user's attributes. If the user doesn't exist, a new user will be created.
## Body Parameters
Unique identifier for the user. This should match the user's ID in your system.
A map of user attributes to update. You can include any custom attributes. Existing attributes not included in the request will remain unchanged. See [Attributes](/api-reference/attributes) for details.
An array of company objects to associate with the user. Each company object must contain an ID and can include additional attributes. This is the recommended approach when you only need to establish company associations without additional relationship attributes.
Unique identifier for the company. Must match the company's ID in your system.
A map of company-specific attributes. You can include any custom attributes to describe the company.
An array of membership objects that define the relationship between a user and a company. Use this when you need to specify attributes that describe the user's relationship with each company. Each membership object must include a company object with at least its ID.
A map of attributes that describe the user's relationship with the company. You can include any custom attributes to define the nature of this relationship.
The company object associated with this membership.
Unique identifier for the company. Must match the company's ID in your system.
A map of company-specific attributes. You can include any custom attributes to describe the company.
Only one of companies and memberships can be set.
```bash Basic Request theme={null}
curl https://api.usertour.io/v1/users \
-XPOST \
-H 'Authorization: Bearer ak_123456789' \
-H 'Content-Type: application/json' \
-d '{
"id": "usr_123456789",
"attributes": {
"name": "John Doe",
"email": "john.doe@example.com",
"signed_up_at": "2024-03-20T08:30:00.000Z"
}
}'
```
```bash With Companies theme={null}
curl https://api.usertour.io/v1/users \
-XPOST \
-H 'Authorization: Bearer ak_123456789' \
-H 'Content-Type: application/json' \
-d '{
"id": "usr_123456789",
"attributes": {
"name": "John Doe",
"email": "john.doe@example.com"
},
"companies": [
{
"id": "comp_987654321",
"attributes": {
"name": "Acme Corporation",
"plan": "enterprise",
"industry": "technology"
}
}
]
}'
```
```bash With Memberships theme={null}
curl https://api.usertour.io/v1/users \
-XPOST \
-H 'Authorization: Bearer ak_123456789' \
-H 'Content-Type: application/json' \
-d '{
"id": "usr_123456789",
"attributes": {
"name": "John Doe",
"email": "john.doe@example.com"
},
"memberships": [
{
"attributes": {
"role": "admin",
"department": "engineering",
"join_date": "2024-01-01T00:00:00.000Z"
},
"company": {
"id": "comp_987654321",
"attributes": {
"name": "Acme Corporation",
"plan": "enterprise",
"industry": "technology"
}
}
}
]
}'
```
```json Response theme={null}
{
"id": "usr_123456789",
"object": "user",
"attributes": {
"name": "John Doe",
"email": "john.doe@example.com"
},
"created_at": "2024-03-20T08:30:00.000Z"
}
```
# Create or update a user
Source: https://docs.usertour.io/api-reference/users/create-or-update-a-user
/api-reference-v2/openapi.json put /v2/projects/{projectId}/environments/{environmentId}/users/{id}
# Delete a user
Source: https://docs.usertour.io/api-reference/users/delete
Delete /v1/users/:id
## Path Parameters
Unique identifier for the user. This should match the user's ID in your system.
## Response
Unique identifier for the user. This should match the user's ID in your system.
Represents the object's type. Always "user".
Indicates whether the user was successfully deleted. Always true when the request succeeds.
```bash Request theme={null}
curl https://api.usertour.io/v1/users/usr_123456789 \
-XDELETE \
-H 'Authorization: Bearer ak_123456789'
```
```json Response theme={null}
{
"id": "usr_123456789",
"object": "user",
"deleted": true
}
```
# Delete a user
Source: https://docs.usertour.io/api-reference/users/delete-a-user
/api-reference-v2/openapi.json delete /v2/projects/{projectId}/environments/{environmentId}/users/{id}
DESTRUCTIVE and permanent — removes the user AND everything hanging off them in this environment: attributes, company memberships, segment memberships, question answers and event history. There is no restore; re-identifying the same external id later starts a brand-new user.
# Get a user
Source: https://docs.usertour.io/api-reference/users/get
Get /v1/users/:id
Retrieves a specific user by their ID, including all their attributes and relationships.
## Path Parameters
The unique identifier of the user to retrieve. This ID should match the user's identifier in your system.
## Query Parameters
See [Expanding objects](/api-reference/expanding-objects). Available options:
* `memberships`: Include basic membership data for each user
* `memberships.company`: Include both membership and associated company details
* `companies`: Include all companies associated with the user
* You can request multiple expansions: `expand[]=memberships&expand[]=companies`
## Response
Returns a [user object](/api-reference/users/model) if found. Returns a 404 Not Found error if the user doesn't exist.
```bash Request theme={null}
# Get a user with their memberships, company details, and membership-company relationships
curl https://api.usertour.io/v1/users/usr_123456789?expand[]=memberships&expand[]=memberships.company&expand[]=companies \
-H 'Authorization: Bearer ak_123456789' \
-H 'Content-Type: application/json'
```
```json Response theme={null}
{
"id": "usr_123456789",
"object": "user",
"attributes": {
"name": "John Doe",
"email": "john.doe@example.com",
"role": "admin",
"department": "engineering",
"signed_up_at": "2024-03-20T08:30:00.000Z"
},
"createdAt": "2024-03-20T08:30:00.000Z",
"companies": [
{
"id": "comp_123456789",
"object": "company",
"attributes": {
"name": "Acme Corp",
"domain": "acme.com"
},
"createdAt": "2024-03-20T08:30:00.000Z"
}
],
"memberships": [
{
"id": "cma9nhdis0002108kpmr9vfrl",
"object": "companyMembership",
"attributes": {
"role": "admin",
"department": "engineering"
},
"createdAt": "2024-03-20T08:30:00.000Z",
"companyId": "comp_123456789",
"userId": "usr_123456789",
"company": {
"id": "comp_123456789",
"object": "company",
"attributes": {
"name": "Acme Corp",
"domain": "acme.com"
},
"createdAt": "2024-03-20T08:30:00.000Z"
}
}
]
}
```
# Get a user
Source: https://docs.usertour.io/api-reference/users/get-a-user
/api-reference-v2/openapi.json get /v2/projects/{projectId}/environments/{environmentId}/users/{id}
# List users
Source: https://docs.usertour.io/api-reference/users/list
Get /v1/users
Retrieve a paginated list of users. You can filter the results using various query parameters.
## Query parameters
Filter users by their email address. The search is case-insensitive and must match the exact email value stored in the user's attributes.
Filter users by segment membership. You can find the segment ID in the Usertour UI by clicking the three-dot menu in the top right corner of the segment page.
Filter users by company membership. Only returns users who are members of the specified company.
Specifies the maximum number of items to return in a single response. The value must be between 1 and 100. If not specified, defaults to 20 items per page.
Specifies the starting point for the next page of results. The response will include items that come after (but not including) the object with this ID. To get the next page, use the ID of the last item from your previous request. The easiest way is to use the [list object's](#the-list-object) cursor field. If not provided, the API will return items from the beginning of the list.
See [Expanding objects](/api-reference/expanding-objects). Available options:
* `memberships`: Include basic membership data for each user
* `memberships.company`: Include both membership and associated company details
* `companies`: Include all companies associated with the user
* You can request multiple expansions: `expand[]=memberships&expand[]=companies`
## Response
Returns a [list object](/api-reference/pagination#the-list-object) containing an array of [user objects](/api-reference/users/model) in the `results` property. The response includes pagination information in the `next` and `previous` fields.
```bash Request theme={null}
curl https://api.usertour.io/v1/users \
-H 'Authorization: Bearer ak_123456789' \
-H 'Content-Type: application/json'
```
```json Response theme={null}
{
"results": [
{
"id": "usr_123456789",
"object": "user",
"attributes": {
"name": "John Doe",
"email": "john.doe@example.com",
"role": "admin",
"department": "engineering",
"last_login_at": "2024-03-20T08:30:00.000Z"
},
"createdAt": "2024-03-20T08:30:00.000Z",
"companies": null,
"memberships": null
},
{
"id": "usr_987654321",
"object": "user",
"attributes": {
"name": "Jane Smith",
"email": "jane.smith@example.com",
"role": "user",
"department": "marketing",
"last_login_at": "2024-03-19T15:45:00.000Z"
},
"createdAt": "2024-03-19T15:45:00.000Z",
"companies": null,
"memberships": null
}
],
"next": "/v1/users?limit=2&cursor=usr_987654321",
"previous": null
}
```
# List users
Source: https://docs.usertour.io/api-reference/users/list-users
/api-reference-v2/openapi.json get /v2/projects/{projectId}/environments/{environmentId}/users
# The user object
Source: https://docs.usertour.io/api-reference/users/model
A unique identifier for the user. This ID should match the user's identifier in your system.
The object type identifier. Always set to "user" to distinguish it from other API objects.
A collection of user attributes. You can include any custom attributes to describe the user. See [Attributes](/api-reference/attributes) for details.
The timestamp when the user was created in the system, in ISO 8601 format (YYYY-MM-DDTHH:mm:ss.sssZ). Note: This is not the user's signup time in your application - use a custom attribute like `signed_up_at` for that.
A list of [company objects](/api-reference/companies/model) that the user belongs to. This field is useful when you only need company information without membership details. If you need membership attributes (like roles or permissions), use the `memberships` field instead. Can be expanded using `?expand=companies`.
A list of [company membership objects](/api-reference/company-memberships/model) representing the user's company memberships. Each membership includes:
* Company-specific attributes (e.g., role, access level)
* Reference to the associated [company object](/api-reference/companies/model)
Available expansion options:
* `?expand=memberships`: Include basic membership data
* `?expand=memberships.company`: Include both membership and company details
```json Example user object theme={null}
{
"id": "1744521292871a",
"object": "user",
"attributes": {
"name": "John Doe",
"email": "john@example.com",
"role": "admin"
},
"createdAt": "2025-04-27T13:39:47.024Z",
"companies": [
{
"id": "1744521292871a",
"object": "company",
"attributes": {
"name": "Acme Corp",
"domain": "acme.com"
},
"createdAt": "2025-04-27T13:39:47.024Z"
}
],
"memberships": [
{
"id": "cma9nhdis0002108kpmr9vfrl",
"object": "companyMembership",
"attributes": {
"role": "admin",
"department": "engineering"
},
"createdAt": "2025-05-04T12:51:10.881Z",
"companyId": "cm9zp4wyh00116pt4o5v5vg5t",
"userId": "cm9zp4wyb000z6pt484wnbzs7",
"company": {
"id": "1744521292871a",
"object": "company",
"attributes": {
"name": "Acme Corp",
"domain": "acme.com"
},
"createdAt": "2025-04-27T13:39:47.024Z"
}
}
]
}
```
# Create a webhook
Source: https://docs.usertour.io/api-reference/webhooks/create-a-webhook
/api-reference-v2/openapi.json post /v2/projects/{projectId}/environments/{environmentId}/webhooks
On Usertour Cloud, webhooks need a paid plan (Starter or above) — a Hobby project gets 403 E0043. Self-hosted instances are never gated. The same applies to update, rotate-secret and delivery; reads and delete stay available on any plan.
# Delete a webhook
Source: https://docs.usertour.io/api-reference/webhooks/delete-a-webhook
/api-reference-v2/openapi.json delete /v2/projects/{projectId}/environments/{environmentId}/webhooks/{id}
# Get a webhook (includes the signing secret for webhook:manage tokens)
Source: https://docs.usertour.io/api-reference/webhooks/get-a-webhook-includes-the-signing-secret-for-webhook:manage-tokens
/api-reference-v2/openapi.json get /v2/projects/{projectId}/environments/{environmentId}/webhooks/{id}
The signing secret is the ability to forge signed deliveries, so it rides only on tokens holding webhook:manage — a read-only token gets every other field.
# List webhooks
Source: https://docs.usertour.io/api-reference/webhooks/list-webhooks
/api-reference-v2/openapi.json get /v2/projects/{projectId}/environments/{environmentId}/webhooks
# Rotate the signing secret
Source: https://docs.usertour.io/api-reference/webhooks/rotate-the-signing-secret
/api-reference-v2/openapi.json post /v2/projects/{projectId}/environments/{environmentId}/webhooks/{id}/rotate-secret
# Update a webhook
Source: https://docs.usertour.io/api-reference/webhooks/update-a-webhook
/api-reference-v2/openapi.json patch /v2/projects/{projectId}/environments/{environmentId}/webhooks/{id}
# Build Your Onboarding with AI
Source: https://docs.usertour.io/build-onboarding-with-ai
Watch an AI assistant build production-ready onboarding — a flow, a checklist, a survey, an announcement and a resource center — in a real app, then follow along with the exact prompts.
Usertour is open-source user onboarding with an [MCP server](/api-reference-v2/mcp)
built in: connect an AI assistant like Claude Code, Cursor or Codex, describe the
experience you want, and it authors, themes and publishes the content directly in
your project — matched to your app's design, verified in your running app.
The video below does exactly that against a real app
([shadcn-admin](https://github.com/satnaing/shadcn-admin)): four prompts, and
after each one — the result live in the app, then the content and data in the
Usertour dashboard.
## Before you start
All you need is a Usertour account and an AI assistant connected to the MCP
server. With Claude Code that's the plugin (it registers the MCP connection,
the authoring skills, and the SDK-install skill in one):
```text theme={null}
/plugin marketplace add usertour/skills
/plugin install usertour@usertour
```
Then run `/mcp` and authorize in the browser — pick the project, the
environments, and the access level. That's the whole setup: you don't even
install the [Usertour SDK](/quickstart) by hand — on the first prompt the
assistant notices it's missing and wires it into the app itself, then builds
the content.
**Self-hosting?** The plugin defaults to Cloud — point it at your own
instance before connecting (your exact URL is shown in **Settings → MCP**):
`export USERTOUR_MCP_URL="https:///mcp"`
**Optional, recommended:** also install the
[Chrome DevTools plugin](https://github.com/ChromeDevTools/chrome-devtools-mcp)
so the assistant can open your running app, pick element selectors from the
real DOM, and verify each experience actually renders before publishing —
noticeably better tooltip accuracy.
```text theme={null}
/plugin marketplace add ChromeDevTools/chrome-devtools-mcp
/plugin install chrome-devtools-mcp@chrome-devtools-plugins
```
Using Cursor, Codex, VS Code or another client? See the
[MCP connection guide](/api-reference-v2/mcp) — every client gets the same
one-URL setup, and **Settings → MCP** in the app shows the steps with your
instance's URL pre-filled.
## Step 1 — A feature onboarding flow
```text theme={null}
Using Usertour, build a user onboarding flow for the Create Task feature.
Requirements:
- The visual theme must match the website's existing design.
- The flow must be production-ready and suitable for launch.
- Every step should provide genuine value to users. Avoid adding steps
simply for the sake of having a flow — the experience should help users
understand and successfully use the feature.
```
**In the app** — before building anything, the assistant notices the app has
no Usertour SDK yet and installs it on its own (the loader snippet, `init()`
and `identify()`); then the flow runs against the real UI: tooltips anchor to
the actual Create Task controls, and the styling matches the app because the
agent read the design system first and themed the content to it.
**In the dashboard** — the flow appears under **Content** with its steps,
targeting and a published version; **Analytics** starts counting views and
completions as sessions come in.
## Step 2 — An adoption checklist
```text theme={null}
Create a checklist for the features on the User List page.
Requirements:
- Combine the checklist with relevant onboarding flows to significantly
improve product adoption.
- Ensure production-level quality.
- Match the checklist's visual theme to the website's existing style.
```
**In the app** — a checklist panel lists the User List features as tasks;
completing a task can launch the matching flow, so the two content types work
as one adoption journey.
**In the dashboard** — the checklist shows per-task completion in
**Analytics**, so you can see which features users actually adopt and where
they stall.
## Step 3 — A product feedback survey
```text theme={null}
Create a survey to collect user feedback about the product's different
features, including:
- Feature ratings
- Overall satisfaction
- Usability feedback
- Suggestions for improvement
- Open-ended comments
The survey should be thoughtfully structured and suitable for production use.
```
**In the app** — a multi-question survey renders in the app's own look:
ratings, scales and open-text questions in a sensible order.
**In the dashboard** — every question gets its own breakdown under
**Analytics**: score distributions for the ratings, and the open-text answers
collected per response, ready to read through (or to hand back to the AI to
summarize).
## Step 4 — A welcome announcement + resource center
```text theme={null}
Use the Usertour MCP to create a production-ready welcome announcement and
improve the Resource Center as one cohesive onboarding experience.
Welcome Announcement — use Usertour's native Announcement feature (not a
Flow):
- Use the speech bubble announcement format.
- Target new users only.
- Welcome users and briefly introduce the product's core value.
- Guide users to the User List page with a clear primary CTA.
- Include a link to https://usertour.io/ explaining that the onboarding
experience is powered and maintained by Usertour.
- Keep the copy concise, helpful, and professional.
- Match the website's existing visual style.
- Configure sensible display frequency, dismissal behavior, and audience
targeting.
- Ensure the announcement does not conflict with other active onboarding
experiences.
Resource Center — make it support the same onboarding journey:
- Add an entry that helps users navigate to the User List page.
- Make the experiences created in the previous steps accessible from the
Resource Center.
- Organize resources into clear, user-focused groups.
- Use concise titles and descriptions that explain the value of each
resource.
- Remove or avoid duplicate and low-value entries.
- Match the Resource Center's design to the website's visual theme.
- Configure appropriate visibility and audience rules.
- Ensure all links, actions, flows, surveys, and video content launch
correctly.
- Make the experience accessible, responsive, and production-ready.
```
**In the app** — new users get a speech-bubble welcome pointing them at the
User List page, and the resource center becomes the lasting home for
everything built above: the flow, the checklist and the survey, organized into
groups and launchable on demand.
**In the dashboard** — the announcement and resource center sit alongside the
rest under **Content**, each with its own targeting and analytics, and
**Publish history** records what went live in which environment.
## One more: a one-prompt task
Not everything is a four-step journey — one-off tasks are a single prompt. Here
the assistant builds a video modal, verifies the embed actually plays, and
leaves it as a draft for review instead of publishing:
```text theme={null}
Use the Usertour MCP to create a one-step modal flow featuring this
YouTube video:
https://www.youtube.com/watch?v=ZIaOBAjvc38
Use the title:
**Sam Altman: "Never a Better Time to Do a Startup"**
Add concise supporting copy explaining why this is a uniquely promising
time to build a startup and what viewers can learn from the video.
Include a clear watch action and an easy way to close the modal.
Match the product's visual style, verify that the video embed works
correctly, and save the flow as a draft.
```
## Where to next
The other half of the loop — five prompts that turn the collected data
into rankings, funnels and survey readouts.
Install the SDK and identify your users — everything above builds on it.
Connect any client, see every tool, understand scopes and read-only mode.
Prefer hands-on? Build the same flows, checklists and surveys visually.
Run the whole platform — MCP server included — on your own
infrastructure.
# Flows
Source: https://docs.usertour.io/building-experiences/creating-your-first-flow
Learn how to create and customize interactive flows with Usertour
## Getting Started
Before you can start creating flows, you'll need to install [Usertour.js](/developers/usertourjs-reference/installation) in your web application. Once that's done, you're ready to create your first flow!
## Examples
### Page navigation in flow
### Automatic navigation with hidden steps
### User page guidance
### Collect user feedback with surveys
## Creating a New Flow
Here's how to create a new flow in Usertour:
1. Go to **Flows** in the sidebar
2. Click **Create flow**
3. Give your flow a name
4. Click **Submit**
## Add Step
Follow these steps to add a step:
1. Navigate to the flow detail page
2. Click **Edit in builder** at the top
3. Click the **Create** button on the left side of the builder
4. Select a **Step Type** (Tooltip, Modal, Speech bubble, Hidden)
5. Edit the Step content
### Step types
* **Speech bubble** — Renders at a fixed position (set in themes) and is not tied to any on-page element. Best for opening a flow with a conversational, action-focused message.
* **Tooltip** — Attaches hints and prompts to specific elements in your app. Best for walking users through a particular task or workflow.
* **Modal** — Overlays your app and blocks interaction with the page until the user dismisses it or moves to the next step. Best for single-step announcements, flow start prompts, or flow-end messages (e.g. with confetti).
* **Hidden** — Runs a user interaction that advances the flow without showing any UI. Best for pausing until the user does something, or sending them to a given page automatically.
## Theme
Make your flows look and feel just right for your application. With themes, you can customize colors, typography, and overall styling to match your brand.
## Tooltip Placement (Tooltips only)
You can target elements using CSS Selector, with optional Element Text for more precise selection:
* **CSS Selector**: Required for targeting elements. Use any valid CSS selector to specify which element to target
* Works with all standard CSS selectors (see [MDN's CSS Selectors](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors))
* Uses `querySelectorAll()` to find elements
* When multiple elements match, Usertour picks the first one
* You can specify which element to select by providing its index
* **Element Text**: Optional. Use this when you need to distinguish between elements that share the same CSS selector but have different text content
Instead of typing the selector by hand, you can point at the element with the built-in picker. See [Selecting Elements](/how-to-guides/selecting-elements) for how it works — and for defining stable selectors so flows don't break on a release.
## Tooltip Alignment (Tooltips only)
Fine-tune how your tooltips align with their target elements:
* Choose your alignment mode:
* **Auto**: Tooltip will automatically adjust to the best available position if the preferred alignment doesn't fit
* **Fixed**: Tooltip will maintain the exact alignment you specify, regardless of available space
* When using Fixed mode, choose from multiple alignment options:
* Left: left-start, left-center, left-end
* Right: right-start, right-center, right-end
* Top: top-start, top-center, top-end
* Bottom: bottom-start, bottom-center, bottom-end
* Each alignment option determines how the tooltip connects to its target element
* Preview the alignment in real-time in the builder
## Backdrop
The backdrop helps keep users focused:
* Creates a semi-transparent overlay
* Highlights important elements
* Prevents interaction with background content
* Works with all step types:
* Modal steps: Always on
* Tooltip steps: Optional
* Customize in theme settings
## Actions
For each step, you can set up actions that happen when:
* Users click buttons
* Users interact with tooltip targets
* Step trigger conditions are met
Here's what you can do:
* **Go to step** - Jump to any step in your flow. Perfect for creating non-linear flows and conditional paths.
* **Dismiss** - End the current flow. You can trigger this through code or user interaction.
* **Start new flow** - Switch to a different flow while ending the current one. Great for smooth transitions between experiences.
* **Navigate to page** - Open a specific URL in the browser. Choose between same-tab or new-tab navigation.
* **Evaluate javascript** - Run custom JavaScript code in your application. This lets you integrate deeply with your app's features.
## Step Triggers
Triggers let you create smart flows with **"if this, then that"** rules. Before showing each step, if a trigger is active, it'll run its logic first. You can:
* Add multiple triggers to a step
* Create branching flows
* Skip steps based on conditions
A trigger can have the following conditions:
* **User attribute** - Execute the trigger's action when a user possesses specific attributes, enabling personalized flow progression.
* **Current page is** - Execute the trigger's action when users navigate to a designated page, ensuring context-aware flow delivery.
* **Element is present/clicked/disabled** - Execute the trigger's action based on UI element states. Particularly useful for handling asynchronous content loading scenarios.
* **Text input value is** - Execute the trigger's action when users enter specific values in designated input fields, enabling precise form-based flow control.
* **User fills in input** - Execute the trigger's action upon any user input in specified fields, facilitating real-time flow progression.
* **Current time** - Execute the trigger's action at designated times, allowing for time-sensitive content delivery within specific time periods.
Best Practice: Add triggers to visible steps to prevent users from getting stuck.
## Reordering Steps
Want to change the order of your steps? Just drag and drop them to rearrange your flow.
Feel free to add new steps or rearrange existing ones to create the perfect user journey.
## Starting a Flow
Once you've set up your flow, you'll need to know how to start it for your users. For the full details on starting flows, check out the [Starting Flow/Checklist Guide](/how-to-guides/starting-flows).
Remember to install [Usertour.js](/developers/usertourjs-reference/installation) in your application to start flows for your users.
## Temporarily Hiding Flows
To show flows only on specific pages:
1. Turn on **Temporarily hide flow**
2. Set your visibility conditions
Use auto-start conditions for targeting flows instead of temporary hiding.
## Dismissing Flows
Users can end a flow anytime by clicking the X-button in the top-right corner of modals and tooltips. While you can disable this button in advanced settings, we recommend keeping it enabled. You can add **Dismiss** actions to buttons, tooltip target clicks, survey questions, and step triggers. When starting a new flow, the current one will automatically end. For other actions like page navigation, you'll need to add a **Dismiss** action yourself.
On the last step of a flow, we recommend adding a **Dismiss** action to give users a clear way to end the flow. If you use **Start a flow** to begin another flow, the current one will automatically end. This automatic ending doesn't happen with other actions like **Navigate to page** - you'll need to add a **Dismiss** action yourself.
Make sure your flow is properly dismissed (or hidden using temporarily hide), as an undismissed flow will prevent other flows from starting.
Currently, flows have a 24-hour session lifecycle. This prevents flows from getting stuck undismissed and blocking other flows permanently. In the future, you'll be able to configure this duration through the Usertour.js API.
## Flow Completion
Flow completion is different from dismissal. A flow is complete when users reach the completion step, which is automatically set as the last step. If you want the flow to complete only after a specific button click on the last step, add a hidden step after the button step with an unconditional trigger and **Dismiss** action.
# Understanding In-App Flows
Source: https://docs.usertour.io/building-experiences/understanding-in-app-flows
Learn how to create effective in-app flows to guide users through your product with interactive tours, tooltips, and modals.
## What is a Flow?
Think of a flow as your product's friendly tour guide. Just like you'd show a friend around your home, a flow helps new users explore your product, discover its features, and get started with confidence.
A flow appears as an interactive overlay in your app that helps users:
* Get familiar with your product
* Find features they might not know about
* Complete important tasks with ease
* Get help right when they need it
Each step in your flow is a different way to connect with your users. You can use:
* Modals for important announcements
* Tooltips for quick feature tips
* Slideouts for detailed information
* Checklists for complex tasks
The best part about flows is how flexible they are. You can mix these different types to create exactly the experience your users need. For example, you might:
* Show a single modal to announce a new feature
* Create a multi-step tour to welcome new users
* Add a tooltip to highlight a specific button
* Use a checklist to guide users through a complex task
## Best Practices
Creating great flows is all about understanding your users and their needs. Here are some tips to help you create flows that users will love:
### Keep It Simple
Less is more! Aim for five steps or less in your flows. Use friendly, clear language and focus on one thing at a time. Too much information at once can be overwhelming.
### Know Your Users
Different users need different guidance. A new user needs more help than someone who's been using your product for months. Make sure you're showing the right content to the right people at the right time.
### Start With a Goal
Before you start building, ask yourself: "What do I want my users to learn or accomplish?" Whether it's discovering a new feature or completing a complex task, having a clear goal will help you create more effective flows.
### Make It Feel Natural
Your flows should feel like a natural part of your product. Use consistent styling, make navigation intuitive, and always let users skip if they want to. Don't forget to test your flows on different devices to ensure everyone has a great experience.
# 2025
Source: https://docs.usertour.io/changelog/2025
Usertour releases in 2025
## 🚀 Usertour v0.4.6
This release focuses on **builder stability**, **launch rules UX**, and **SDK / WebSocket robustness**.\
It fixes many long-standing edge cases and makes Usertour more reliable in production.
### ✨ Highlights
* Builder save reliability improved (unsaved warnings, fast-click save fix)
* Launch rules UX optimized (auto-expand, checklist support)
* Analytics accuracy improved (step stats now keyed by `cvid`)
* WebSocket v2 hardened (throttling, validation, deadlock fixes)
* Environments now support **Primary** setting
### 🧩 Builder & UX
* Unsaved changes warning when navigating back
* Back button & auto-save behavior optimized
* Copy / duplicate content fixes
* Missing or invalid configs now show explicit errors
* SDK & Builder UI consistency fixes
### 🚀 Rules & Conditions
* Checklist supported in launch rules
* Rule selection now opens config immediately
* Rules evaluator rewritten with cache isolation
* Date conditions now support common presets
### 📊 Analytics
* Step funnel & completed step icon fixed
* Tooltip missing data issues resolved
### 🔌 SDK, Embed & WebSocket
* WebSocket throttling, param validation, and better logging
* Prevented potential WebSocket deadlocks
### 🔐 Auth & Infra
* Login flow improved with refresh token support
* Removed Google Analytics from app
**Full Changelog**\
[https://github.com/usertour/usertour/compare/v0.4.5...v0.4.6](https://github.com/usertour/usertour/compare/v0.4.5...v0.4.6)
## ✨ Improvements
* **Enhanced Docker production environment**
* Added `NODE_ENV=production` environment variable by default, ensuring proper production mode operation.
* Updated CMD instruction to shell form for proper stdout/stderr log flushing, improving container log visibility.
* **Improved database migration reliability**
* Added automatic retry mechanism for database migrations with configurable attempts via `DB_MIGRATE_RETRIES` environment variable (default: 3 retries).
* Migration failures now provide clearer error messages and graceful retry handling.
* **Optimized Docker file structure**
* Reorganized startup scripts to `/app/scripts/` directory for better maintainability.
***
**Full Changelog:** [v0.4.4...v0.4.5](https://github.com/usertour/usertour/compare/v0.4.4...v0.4.5)
### 🐛 Bug Fixes
* **Fixed compatibility with numeric `userId` / `companyId`.**
The SDK now properly handles number types by converting them to strings automatically.
### ✨ Improvements
* **Optimized exception handling in the SDK**
Improved error clarity and reliability across various SDK operations.
* **Enhanced Docker experience**
* Added default `PORT` and `NEST_SERVER_PORT` environment variables in the Dockerfile, so most users no longer need to configure them manually.
* Improved port handling and startup checks to avoid conflicts and provide clearer logs.
***
**Full Changelog:** [https://github.com/usertour/usertour/compare/v0.4.3...v0.4.4](https://github.com/usertour/usertour/compare/v0.4.3...v0.4.4)
## 🐛 Bug Fixes
* Socket Reconnection Handling – Fixed an issue where the SDK would fail silently when the initial WebSocket connection failed. The SDK now waits for Socket.IO automatic reconnection instead of giving up on the first error, ensuring proper initialization after network recovery.
* Connection Promise Caching – Added connection promise caching to prevent duplicate connection attempts when identify() is called multiple times in quick succession for the same user.
* Timer Management – Unified all timer operations to use timerManager, ensuring proper cleanup when reset() is called and preventing memory leaks.
**Full Changelog**: [https://github.com/usertour/usertour/compare/v0.4.2...v0.4.3](https://github.com/usertour/usertour/compare/v0.4.2...v0.4.3)
## 🐛 **Bug Fixes**
* **Conditional Usertour Initialization** – Added a check to ensure `userTourToken` exists before calling `usertour.init()` and `usertour.identify()`. This prevents initialization errors in environments where the Usertour token is not configured.
***
**Full Changelog**: [https://github.com/usertour/usertour/compare/0.4.1...v0.4.2](https://github.com/usertour/usertour/compare/0.4.1...v0.4.2)
## 🛠 **Dev & Infrastructure**
* **Multi-Architecture Docker Build Support** – Docker images now support building for multiple architectures (x86\_64, ARM64) without manual configuration.
* **Dynamic Prisma Client Generation** – Prisma Client is now generated at build time rather than relying on hardcoded binary targets, ensuring correct binaries for the target platform.
* **Simplified Prisma Configuration** – Removed `binaryTargets` from `schema.prisma`, allowing Prisma to auto-detect the correct platform during build.
* **Improved Dockerfile** – Added OpenSSL dependencies in the build stage for proper version detection; streamlined runtime dependencies.
* **Cleaner docker-compose.yml** – Removed `platforms` restriction and `PRISMA_BINARY_PLATFORM` environment variable for better cross-platform compatibility.
***
**Full Changelog**: [https://github.com/usertour/usertour/compare/v0.4.0...0.4.1](https://github.com/usertour/usertour/compare/v0.4.0...0.4.1)
## 🚀 **Core Architecture Upgrades**
* **Rewritten SDK & WebSocket Core** – Usertour.js and the real-time layer have been fully rebuilt to better leverage **Socket.IO**, enabling more reliable real-time communication.
* **Multi-Tab Sync** – Launcher, flows, and UI state are now synchronized across browser tabs. Showing or closing content in one tab instantly reflects in others.
* **Smarter Reconnect & Dedup Logic** – Network reconnection and edge-case handling have been significantly improved to avoid repeated execution and inconsistent UI states.
* **Centralized Timer Manager** – Introduced a `timerManager` to manage all `setTimeout`, `setInterval`, and heartbeat checks in one place, improving performance and stability.
***
## ✅ **Checklist & Launcher Improvements**
* **Always-Update Content** – Checklist and Launcher now **always load the latest published version**. Once published, users see the newest content without manual refresh.
* **Checklist Session Snapshots** – Session details now include checklist snapshots (hidden, clicked, and completed states for each item).
* **Auto Dismiss Countdown** – When checklist auto-dismiss is enabled, a live countdown progress bar is now displayed.
***
## 🎨 **Editor & Theme Fixes**
* Fixed an issue where **Backdrop opacity** could not be edited.
* Fixed an issue where **Highlight type** changes were not saved correctly.
***
## 🔄 **Flow & Versioning Behavior**
* **Smart Version Checking** – When starting a flow, the system now compares the latest published version with the active session version. If they don’t match, a fresh session is created using the latest version.
* **Improved Element Matching** – Flow targeting rules are now more reliable when DOM elements change dynamically.
***
## 🛠 **Dev & Infrastructure**
* Added `REDIS_USER` to `.env.example` for clearer Redis configuration.
* Improved `EMAIL_SENDER` documentation for better setup clarity.
* Added detailed **emit-with-ack timing logs** to improve observability.
* Multiple UX improvements and internal refactors.
***
## 🐞 **Bug Fixes**
* Fixed issues related to published version updates.
* Fixed edge cases that could cause released versions to be overwritten.
* Fixed incorrect theme display behavior during rename operations.
***
**Full Changelog**:
[https://github.com/usertour/usertour/compare/v0.3.3...v0.4.0](https://github.com/usertour/usertour/compare/v0.3.3...v0.4.0)
* Remove the “Temporarily hide” feature from **Launcher**
* Rename **Auto-start launcher if...** → **Show launcher if...**
* Updated logic: *Show the launcher if the user matches the given condition. If the user doesn’t match the condition, the launcher will not be displayed.*
* Improved flow rule evaluation when elements change
* Optimized **Launcher** element detection — now uses the same mechanism as **Flow**, resulting in more accurate targeting
**Full Changelog**: [https://github.com/usertour/usertour/compare/v0.3.1...v0.3.2](https://github.com/usertour/usertour/compare/v0.3.1...v0.3.2)
* Update env example by @winter100004 in [https://github.com/usertour/usertour/pull/220](https://github.com/usertour/usertour/pull/220)
* Update .env.example by @winter100004 in [https://github.com/usertour/usertour/pull/221](https://github.com/usertour/usertour/pull/221)
* Fix password error message by @winter100004 in [https://github.com/usertour/usertour/pull/223](https://github.com/usertour/usertour/pull/223)
* Enhance docker-compose configuration with health checks by @winter100004 in [https://github.com/usertour/usertour/pull/224](https://github.com/usertour/usertour/pull/224)
**Full Changelog**: [https://github.com/usertour/usertour/compare/v0.3.1...v0.3.2](https://github.com/usertour/usertour/compare/v0.3.1...v0.3.2)
## 🚀 Usertour v0.3.1 — Smarter license support, better caching
A small but important release focused on smoother upgrades and license key management.
### ✨ New Features
**License Key Upload**
You can now upload license keys directly from the admin panel — making it easier for self-hosted customers to unlock paid features.
### 🛠 Improvements
**Smarter Nginx Configuration**
We’ve updated the default Nginx setup to avoid browser caching issues with `usertour.js` and `usertour.iife.js` during upgrades. No more mysterious version mismatches.
***
**Full Changelog**: [https://github.com/usertour/usertour/compare/v0.3.0...v0.3.1](https://github.com/usertour/usertour/compare/v0.3.0...v0.3.1)
## ✨ New Features
**OpenTelemetry Integration**
Usertour now supports \[[OpenTelemetry](https://opentelemetry.io/)]\([https://opentelemetry.io/](https://opentelemetry.io/)) out of the box.
You can export both traces and metrics by configuring your preferred endpoint — perfect for production observability.
* Report WebSocket session metrics in real time
* Trace request flows across your system
* Easily integrate with Grafana, Prometheus, and more
**License Key Support for Paid Features**
We’ve added license key validation to support our self-hosted Business and Enterprise plans.
Just drop in your license key and unlock premium features without cloud dependency.
**Workspace-based Module Resolution**
Cleaner internal structure and faster local development using workspaces.
**Custom Cursor Configuration**
You can now customize the pointer style (cursor) for tour steps — useful for highlighting interactive elements in your app.
## 🛠 Improvements
**Smarter Launcher Handling**
WebSocket sessions now respond immediately for launcher-based tours — improving perceived speed and interactivity.
**Sentry Integration**
We’ve integrated Sentry to help debug issues faster — for both us and those running their own instances.
**Cleaner Package Naming**
Smaller, more consistent packages — easier to read, install, and manage.
**Branding Control**
Added an environment variable to remove Usertour branding — available for paid plans.
## 🐞 Bug Fixes
* No more duplicated analytics calls when using WebSocket events
* Stability improvements across all real-time interactions
* Edge case handling for license validation and missing keys
**Full Changelog**: [https://github.com/usertour/usertour/compare/v0.2.7...v0.3.0](https://github.com/usertour/usertour/compare/v0.2.7...v0.3.0)
## ✨ **New Features**
* **Conditional Variations by Theme**
Usertour now supports **conditional content variations** based on the active theme.
You can target `light` vs `dark` modes, or use user traits and URL rules — perfect for adapting your experience visually.
* **Smarter Content Refresh & Requests**
We've optimized how `usertour.js` fetches and syncs content:
* Fewer network requests
* Faster response between steps
* Smoother transitions on click
***
## 🛠 **Improvements**
* **WebSocket Performance Boost**
* Combined multiple fetches into one parallel call
* Reduced response time on session analytics
* Cleaner query syntax under the hood
* **UI/UX Polishing**
* Better loading and disabled states after deletion actions
* Fewer awkward moments when waiting for UI updates
* **Developer Quality of Life**
* Avatar fallback fixes in environment switcher
* Improved session navigation with wrapped links
***
## 🐞 **Bug Fixes**
* No more unnecessary `listContents` requests on `SendEvent`
* Segment filtering is now rock-solid
***
## 🙌 **Shoutout**
Thanks to @shodown96 for their first contribution! 🎉
***
**Full Changelog**: \[[v0.2.6...v0.2.7](https://github.com/usertour/usertour/compare/v0.2.6...v0.2.7)]\([https://github.com/usertour/usertour/compare/v0.2.6...v0.2.7](https://github.com/usertour/usertour/compare/v0.2.6...v0.2.7))
## 🚀 **New Features**
* **Flexible Progress Bar Styles** – You can now choose from 5 types of tooltip progress bars by setting the `theme`:
* Narrow Progress Bar
* Chain (Rounded or Squared)
* Dots
* Numbered (`1 of 3`)
These options let you better match your product's style and vibe.
* **Auto Dismiss for Checklists** – Add `autodismiss` to your checklist. When users complete all items, the checklist will auto-close — no clicks required.
* **Smarter Flow Logic** – Temporarily hidden content no longer blocks other flows from starting. This makes auto-started content more predictable and reliable.
* **User & Company Session Lists** – You can now view a user's session history, and also see all members under a company. Super helpful for debugging or analytics.
***
## 🛠 **Improvements**
* Replaced all toast notifications with \[[Sonner](https://sonner.emilkowal.ski/)]\([https://sonner.emilkowal.ski/](https://sonner.emilkowal.ski/)) for a cleaner, more modern feel.
* Improved SDK component performance and animations.
* Loading states and spinners added for a smoother experience in detailed content views.
* Segment loading and filtering logic fully optimized.
***
## 🐞 **Bug Fixes**
* Fixed an issue where segment filtering caused incorrect results.
* Resolved bugs related to checklist progress bar display and default behavior.
***
**Full Changelog**: \[[v0.2.5...v0.2.6](https://github.com/usertour/usertour/compare/v0.2.5...v0.2.6)]\([https://github.com/usertour/usertour/compare/v0.2.5...v0.2.6](https://github.com/usertour/usertour/compare/v0.2.5...v0.2.6))
See the [release notes on GitHub](https://github.com/usertour/usertour/releases/tag/v0.2.3).
See the [release notes on GitHub](https://github.com/usertour/usertour/releases/tag/v0.2.2).
See the [release notes on GitHub](https://github.com/usertour/usertour/releases/tag/v0.2.1).
## 🚀 **New Features**
* **New Animations** – Tooltips now include smooth docking transitions. Checklists come with a completion animation and will automatically expand when users complete an item, creating a more dynamic experience.
* **Flow + Checklist = Infinite Logic** – We’ve enhanced how flows and checklists can work together. You can now build complex, multi-branch onboarding logic using a mix of flows and checklists — flexible enough to cover almost any use case.
* **Push-based Updates** – Usertour.js no longer relies on polling to fetch content. Instead, we now use **Socket.IO** to push real-time updates to users — including modals, surveys, and checklists. It’s faster, lighter, and smarter.
* **🔁 Start From a Specific Step** – When triggering a flow from inside another flow or checklist, you can now choose which step to start from — not just the first step. This gives you more flexibility in designing contextual onboarding.
***
## 🎨 **UX & Interaction Improvements**
* Improved all loading and debounce logic across the app for smoother data handling.
* Reworked several core components to drastically improve the interactive experience.
* Added confirmation modal when dismissing incomplete checklists (fully completed ones dismiss instantly).
* Fixed checklist launcher sizing issues.
* Optimized checklist item interactivity and launcher visuals.
***
## 🐞 **Bug Fixes**
* Fixed an issue where attribute selectors could make the page unclickable.
* Added `try-catch` around `eval()` usage to prevent runtime crashes caused by malformed logic.
* Fixed flow startup rule behavior when used with checklists and branching flows.
***
**Full Changelog**: [https://github.com/usertour/usertour/compare/v0.1.13...v0.2.0](https://github.com/usertour/usertour/compare/v0.1.13...v0.2.0)
## 🚀 **New Features**
* **Programmatic Flow Start** – Added a `start()` method to trigger flows, checklists, or guides on demand. Great for custom triggers like a “Replay Tutorial” button in your app.
* **Self-hosted Mode Member Invites** – Now you can invite team members when using Usertour in self-hosted mode.
***
## 🐞 **Bug Fixes**
* Improved error handling on the startup rules page — invalid or missing rules won’t auto-save and now show clear error messages.
* Fixed flow activation and deactivation issues related to startup rules.
* Added exception handling to the list-content API to prevent crashes from misconfigurations.
***
## 🎨 **Improvements**
* Enhanced editor default placeholder text for a smoother editing experience.
***
**Full Changelog**: [https://github.com/usertour/usertour/compare/v0.1.12...v0.1.13](https://github.com/usertour/usertour/compare/v0.1.12...v0.1.13)
## 🚀 **New Features**
* **Project-level content support** – Content is no longer tied to a single environment. Now, the same content (and its versions) can be published to different environments within the same project.
This better matches a typical developer release workflow — for example:
➤ Publish to `development` →
➤ Verify in `staging` →
➤ Release to `production`.
Content sessions, users, companies, segments, and analytics **remain isolated per environment**, so your data stays clean and separate.
***
## 🐞 **Bug Fixes**
* Fixed an issue where segment filtering could cause **infinite request loops**.
* Fixed the **OpenAPI `end session` endpoint**, which previously could not be used properly.
***
## 🔁 **Removals**
* Removed the **"cross-environment content duplicate"** feature, as it’s no longer necessary under the new project-level content model.
***
**Full Changelog**: [https://github.com/usertour/usertour/compare/v0.1.11...v0.1.12](https://github.com/usertour/usertour/compare/v0.1.11...v0.1.12)
## 🚀 **What's New**
* **Flow session support in Usertour.js** – Flows and checklists now remember where users left off. If they don’t finish, they can pick it up later—seamlessly.
* **Cross-page flows** – Start a tour on one page and continue it on another—even in a new browser tab.
* **Hidden steps** – Use invisible steps to create more dynamic flows while keeping the experience clean and focused.
* **User data deletion** – You can now fully delete user data, reset flow sessions, or dismiss flows for everyone with just a few clicks.
## 🎄 **Improvements**
* Major docs update! We rewrote most of the documentation and added video walkthroughs to help you get up and running faster.
## 🐞 **Fixes**
* Fixed company field handling in the SDK.
* Improved URL matching for better targeting accuracy.
* Survey limits now work as expected.
* Cleaned up and refactored code for improved clarity and performance.
## 📊 **New Contributors**
* Big thanks to @winter100004 for contributing across the board on this release!
**\[[Full Changelog](https://github.com/usertour/usertour/compare/v0.1.10...v0.1.11)]\([https://github.com/usertour/usertour/compare/v0.1.10...v0.1.11](https://github.com/usertour/usertour/compare/v0.1.10...v0.1.11))**
## 🚀 **New Features**
* Launched the completed REST API, allowing you to synchronize your user data with Usertour and track events directly from your back-end application.
* Added company segment support in startup rules, enabling content display control based on the company.
## 🎄 **Enhancements**
* Added Prisma direct URL configuration, allowing support for PgBouncer and other Postgres proxies in production environments.
* Added WebSocket URI path configuration, allowing the use of a given `wsUri` to handle full URLs with protocol, just domain, and relative path.
## 🐞 **Bug Fixes**
* Fixed the spelling of "Acount" to "Account" in the admin user navigation.
## 📊 **New Contributors**
* @omari58 made their first contribution in \[[#71](https://github.com/usertour/usertour/pull/71)]\([https://github.com/usertour/usertour/pull/71](https://github.com/usertour/usertour/pull/71))
🚀 UserTour v0.1.9 Release Notes
💡 Major Updates
Stripe Integration: Added payment functionality with Stripe, including version display for paid features.
Session Data Export: Supports CSV exports with customizable fields (standard user attributes or full user properties).
🔔 Breaking Changes
Socket.IO Scaling: Fixed multi-replica deployment compatibility for Socket.IO (infrastructure impact).
🐞 Bug Fixes
Resolved stability issues in distributed environments.
🔗 Full Changelog: [v0.1.8...v0.1.9](https://github.com/usertour/usertour/compare/v0.1.8...v0.1.9)
💡 **Major Updates**
* Added support for surveys (Multi Line Text, Single Line Text, Multiple Choice, Star Rating, Scale, NPS) by @winter100004 in #51
* Flow analytics now support surveys and session detail viewing by @winter100004 in #51
🎄 **Enhancements**
* Added height parameter by @devcodes9 in #43
* Added content type for duplication feature by @devcodes9 in #49
* Version updated to 0.1.9 with display height label in embed by @winter100004 in #45
🐞 **Bug Fixes**
* Fixed step trigger activation and jump behavior by @winter100004 in #39
* Fixed server hot reload issue by @devcodes9 in #50
👏 **Special Thanks**
* @devcodes9 for multiple contributions (#43, #49, #50)
🔔 **Full Changelog**\
v0.1.7...v0.1.8: [https://github.com/usertour/usertour/compare/v0.1.7...v0.1.8](https://github.com/usertour/usertour/compare/v0.1.7...v0.1.8)
* feat: Update sidebar icons and add new icons to shared package by @winter100004 in [https://github.com/usertour/usertour/pull/26](https://github.com/usertour/usertour/pull/26)
* feat: Update theme configuration with new checklist and launcher styles by @winter100004 in [https://github.com/usertour/usertour/pull/29](https://github.com/usertour/usertour/pull/29)
* refactor: Simplify registration page component structure by @winter100004 in [https://github.com/usertour/usertour/pull/30](https://github.com/usertour/usertour/pull/30)
* fix(web): resolve authentication error for me query before login by @winter100004 in [https://github.com/usertour/usertour/pull/31](https://github.com/usertour/usertour/pull/31)
* enhancement: targetEnvironment added by @devcodes9 in [https://github.com/usertour/usertour/pull/33](https://github.com/usertour/usertour/pull/33)
* enhancement: make targetEnvironmentId optional and update permissions… by @winter100004 in [https://github.com/usertour/usertour/pull/34](https://github.com/usertour/usertour/pull/34)
## New Contributors
* @devcodes9 made their first contribution in [https://github.com/usertour/usertour/pull/33](https://github.com/usertour/usertour/pull/33)
**Full Changelog**: [https://github.com/usertour/usertour/compare/v0.1.6...v0.1.7](https://github.com/usertour/usertour/compare/v0.1.6...v0.1.7)
**💡 Major Updates**
* Added redirect URL support for signup and login flows by @winter100004 in [#15](https://github.com/usertour/usertour/pull/15)
* Introduced member-related features by @winter100004 in [#24](https://github.com/usertour/usertour/pull/24)
**🔔 Breaking Changes**
* Refactored error handling mechanisms by @winter100004 in [#21](https://github.com/usertour/usertour/pull/21)
**🎄 Enhancements**
* Improved word wrapping functionality by @winter100004 in [#23](https://github.com/usertour/usertour/pull/23)
**🐞 Bug Fixes**
* Fixed issue where login configuration could not be obtained for Email, Google, etc. by @winter100004 in [#17](https://github.com/usertour/usertour/pull/17)
* Updated README documentation by @dongfengtaoadmin in [#18](https://github.com/usertour/usertour/pull/18)
* Updated docker-compose command in README by @winter100004 in [#20](https://github.com/usertour/usertour/pull/20)
**👏 Special Thanks**
* @dongfengtaoadmin made their first contribution in [#18](https://github.com/usertour/usertour/pull/18)
**🔔 Full Changelog**
* [v0.1.5...v0.1.6](https://github.com/usertour/usertour/compare/v0.1.5...v0.1.6)
**💡 Major Updates**
* Implemented dynamic authentication provider rendering
* Added environment variables for authentication and file storage
* Supported Google login authentication
**🔔 Breaking Changes**
* Removed unnecessary logging in JWT strategy
* Migrated to a new environment configuration for API routing and authentication
**🎄 Enhancements**
* Enhanced user creation and project initialization during OAuth login
* Added GraphQL query to retrieve authentication provider configuration
* Integrated logging for GraphQL errors and JWT token extraction
**🐞 Bug Fixes**
* Fixed issue where login was not executed after creating a user in the server
💡 Major Updates
* Integrated Biome and Husky for enhanced code quality management
* SDK version upgraded to 0.0.5
* Added placement documentation accessibility in Flow Builder
🔔 Breaking Changes
* Migrated from ESLint/Prettier to Biome for code formatting and linting
* Updated all codebase to comply with Biome standards
🎄 Enhancements
* Added direct documentation link for placement configuration in Flow Builder UI
* Enhanced development workflow with Husky git hooks
* Improved code consistency across the project with Biome integration
🐞 Bug Fixes
* Resolved formatting inconsistencies through Biome implementation
* Fixed code style issues across the codebase
## 🐞 Bug fix
Fix the issue of email sending failure during signup
Fix the self-hosting link in the README is incorrect
Fix Property 'ENV' does not exist on type 'WindowWithUsertour & typeof globalThis'
\#5 #9
## 🚀 Easy Onboarding: Build Flows Fast with Simple Integration and Smart Targeting
🌐 Compatible with all frameworks: If your app runs in a browser, it seamlessly integrates with Usertour.
📄 Supports multi-page apps: Whether it's a single-page application or spans across multiple pages, Usertour fits perfectly.
🎯 Advanced user targeting: Define custom user attributes and track events to segment and engage your audience effectively.
## 🚀 Built for professional workflows with version control and environments
🛠️ Multiple environments supported: Manage environments like Production and Staging within a single Usertour account.
🔄 Version tracking: Monitor every change in your flows, including who made adjustments and when.
## 🚀 Fully customizable appearance
🎨 Tailor your design: Adjust text, button colors, font family, and size to match your branding.
🖌️ Support for multiple themes: Create unique themes for different flows, offering flexibility for varied use cases.
## 🚀 Gain actionable insights with powerful analytics
📊 Performance metrics: Track the effectiveness of your flows with detailed data on views and completion rates.
🚨 Identify problem areas: Pinpoint steps causing user confusion or drop-offs and address the issues seamlessly.
# 2026
Source: https://docs.usertour.io/changelog/2026
Usertour releases in 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, and `page_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 Mixpanel `distinct_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](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](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=false`** skips 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 `/inbound` to 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. `jsforce` leaves 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 to `outbound/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 are `utin_`-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/constants` and provider types split into analytics and automation unions; the Zapier app lives in `integrations/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.
**Full Changelog**: [https://github.com/usertour/usertour/compare/v0.9.3...v0.9.4](https://github.com/usertour/usertour/compare/v0.9.3...v0.9.4)
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 to `user` 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 a `429` 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 as `list_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 stop` always 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_EGRESS` opts 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.
**Full Changelog**: [https://github.com/usertour/usertour/compare/v0.9.2...v0.9.3](https://github.com/usertour/usertour/compare/v0.9.2...v0.9.3)
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 no `API_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
New `TRUST_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 `/mcp` endpoint 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's `X-Forwarded-Proto` with its own scheme. It now passes the header through — including when duplicate headers arrive merged as `https, https`.
* **The port vanished from derived URLs.** nginx's `$host` strips it, so the stock `8011:80` setup derived dead `http://localhost/…` links. The client's own `Host` header is forwarded intact.
* **A bad `TRUST_PROXY` value — 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 and `True` all parse), anything still invalid throws naming the variable, and startup fails fast with exit code 1.
* **`globalConfig` threw over the legacy websocket transport** when `API_URL` was 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** from `Host`, resolved per request, config always winning — and the derived MCP URL must equal the `oauth-protected-resource` metadata byte-for-byte (RFC 9728).
* Rate-limit regression tests cover the spoofing paths: rotating fake `X-Forwarded-For` prefixes 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`).
**Full Changelog**: [https://github.com/usertour/usertour/compare/v0.9.1...v0.9.2](https://github.com/usertour/usertour/compare/v0.9.1...v0.9.2)
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**, with `validate_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
* **`waitMs` was always seconds.** The trigger and start-rule field is renamed `waitSeconds`; 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/helpers` as 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_URL` is the single truth for the advertised MCP resource — set it to serve `/mcp` on 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.
**Full Changelog**: [https://github.com/usertour/usertour/compare/v0.9.0...v0.9.1](https://github.com/usertour/usertour/compare/v0.9.0...v0.9.1)
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.ts` gains a three-projection builder — `createBizUserConditionsFilter` (with all / current / none company-scan modes) and `createBizCompanyConditionsFilter` — 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.
**Full Changelog**: [https://github.com/usertour/usertour/compare/v0.8.9...v0.9.0](https://github.com/usertour/usertour/compare/v0.8.9...v0.9.0)
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 call `identify()` 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](https://docs.usertour.io/developers/identity-verification) 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 of `identify()` 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](https://github.com/usertour/usertour/blob/main/docs/adr/0008-sdk-identity-verification.md) (the verification model) and [ADR 0009](https://github.com/usertour/usertour/blob/main/docs/adr/0009-jwt-identity-tokens.md) (the JWT token format, which replaced an initial bare-HMAC design before release):
* One `checkToken` classifier 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`/`nbf` carry 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 `EnvironmentSigningSecret` table — `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, with `lastUsedAt` observability. 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.
**Full Changelog**: [https://github.com/usertour/usertour/compare/v0.8.8...v0.8.9](https://github.com/usertour/usertour/compare/v0.8.8...v0.8.9)
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](https://docs.usertour.io/how-to-guides/localization) 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 through `AI_*` 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
Set `locale_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 `VersionOnLocalization` rows — 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_code` user 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 `ai` module (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.
**Full Changelog**: [https://github.com/usertour/usertour/compare/v0.8.7...v0.8.8](https://github.com/usertour/usertour/compare/v0.8.7...v0.8.8)
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.
Popup sizing lives in a dedicated **Announcement** section of the theme editor, so popups follow your theme like every other surface.
### 🎯 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:
* `AnnouncementService` owns 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 `BizAnnouncementSeen` table; 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 over `DATABASE_DIRECT_URL` so a saturated connection pooler can't silently skip it.
* `@usertour/types` now holds only types and enums — all instantiated constants moved to `@usertour/constants`.
**Full Changelog**: [https://github.com/usertour/usertour/compare/v0.8.6...v0.8.7](https://github.com/usertour/usertour/compare/v0.8.6...v0.8.7)
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.
**Full Changelog**: [https://github.com/usertour/usertour/compare/v0.8.5...v0.8.6](https://github.com/usertour/usertour/compare/v0.8.5...v0.8.6)
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.
**Full Changelog**: [https://github.com/usertour/usertour/compare/v0.8.4...v0.8.5](https://github.com/usertour/usertour/compare/v0.8.4...v0.8.5)
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 `__typename` tag, 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 `InMemoryCache` switched to normalized mode with per-mutation `update(cache)` callbacks. The app-wide default is `no-cache`, with an explicit `SHARED_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, `AppContext` is now a thin facade over identity hooks, and the remaining raw `useQuery` / `useMutation` calls are wrapped in
`@usertour/hooks`.
* List pagination unified into three documented primitives — `useCursorPagination` (page buttons), `useLoadMoreAccumulator` (load-more), and a shared `useCursorFetchMore` request-side helper — with cache-level merge owned by typePolicy accumulators.
* Users and companies UI deduplicated into shared `components/segments/entity/*` components.
* `apollo3-cache-persist` dropped (in-memory cache only); the analytics session table's row-shared queries hoisted from per-row to per-table (\~80 → 3 observers); a lighter `useShouldShowMadeWith` reader replaces a three-query `useSubscription` on theme previews.
**Full Changelog**: [https://github.com/usertour/usertour/compare/v0.8.3...v0.8.4](https://github.com/usertour/usertour/compare/v0.8.3...v0.8.4)
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/\/ (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.
🐛 Fixes
* 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).
**Full Changelog**: [https://github.com/usertour/usertour/compare/v0.8.2...v0.8.3](https://github.com/usertour/usertour/compare/v0.8.2...v0.8.3)
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.
🐛 Fixes
* 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").
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.** `getContent` returned 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 `codeName` previously leaked Prisma's `P2002` as a generic 500; it now returns `E0048` (`ResourceAlreadyExistsError`).
* **Deleting a localization with localized version data no longer 500s.** The delete now cascades `VersionOnLocalization` rows in one transaction.
* **Step / version-localization mutations stop bouncing on soft-deleted content.** `contentVersionIsEditable` null-guards before reading `editedVersionId`.
### 🛠️ 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_CAPABILITIES` matrix in `@usertour/constants` is 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 one `PermissionGuard` that 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 `PermissionGuard` with a scope-resolver registry. Each resolver registers how to derive
`projectId` from 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's `useAppContext().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.ts` and drives two surfaces: `permission.e2e-spec.ts` (jest, 312 deny-direction assertions + cross-project IDOR coverage) and `test/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 do `await buildStep(prisma)` and get a fully-wired project → environment → content → version → step
chain in one line.
**Full Changelog**: [https://github.com/usertour/usertour/compare/v0.8.0...v0.8.1](https://github.com/usertour/usertour/compare/v0.8.0...v0.8.1)
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 data
Attributes 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.
Full Changelog: [https://github.com/usertour/usertour/compare/v0.7.9...v0.8.0](https://github.com/usertour/usertour/compare/v0.7.9...v0.8.0)
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 `revoked` rows); 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 `?next` flash 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.
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 through `useTranslation('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 from `useAppContext().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/signin` route on the *API* host. The redirect now prefixes the SPA host
correctly.
### 🛠️ Under the hood
The `apps/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 `CustomRoute` if-chain in `routes/index.tsx` is replaced by ``, a single three-mode state machine that owns every auth-related redirect (setup-admin enforcement, sign-in bounce with `?next=`,
2FA enrolment). `routes/config.ts` becomes a nested `RouteObject` tree with `React.lazy` per-page chunks; page-area providers (Environment / Attribute / Subscription) mount once at the user-area parent and stop remounting on every navigation.
* Seven hand-rolled `` + `` blocks across the auth pages collapse into one `` shell that owns the title chrome, description, footer slot, and loading skeleton. The three legacy React contexts that shadowed `react-hook-form`'s
`
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.
🧪 Tracker editor
* 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.
🚦 Publish gating
* 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.
🐛 Fixes
* 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.
Full Changelog: [https://github.com/usertour/usertour/compare/v0.7.6...v0.7.7](https://github.com/usertour/usertour/compare/v0.7.6...v0.7.7)
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.
🏢 Instance-wide enforcement (self-hosted)
* 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.
🛡️ Password lockout
* 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.
Full Changelog: [https://github.com/usertour/usertour/compare/v0.7.5...v0.7.6](https://github.com/usertour/usertour/compare/v0.7.5...v0.7.6)
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.
🔢 SDK 0.7.0
* @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.
🧹 Why now
The split between @usertour/\* and @usertour-packages/\* had drifted into a marker of "this package has been promoted to a Node-consumable shape yet" — a moving target that forced a rename every time a P1 package was promoted to P2. The v0.7.4 work to
promote @usertour-packages/constants triggered one such rename; codifying the unification now avoids the next one. With one scope, the package shape (P1 / P2) lives entirely in package.json fields and the build pipeline, which is where it belongs.
**Full Changelog**: [https://github.com/usertour/usertour/compare/v0.7.4...v0.7.5](https://github.com/usertour/usertour/compare/v0.7.4...v0.7.5)
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\ maps on the
web pick up the new key.
🛡 Server-side quota enforcement
* 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.
📧 Team invite hardening
* 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).
🎨 Pricing page polish
* 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.
🪝 Quota hooks on the web
* 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.
📘 Architecture doctrine
* 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.
**Full Changelog**: [https://github.com/usertour/usertour/compare/v0.7.3...v0.7.4](https://github.com/usertour/usertour/compare/v0.7.3...v0.7.4)
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 \
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.
🧱 Conditions (schema-driven replacement for Rules)
* 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.
📅 Conditions polish
* 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).
✨ Tabs & UI animation
* 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.
🛡️ Multi-environment safety
* 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.
📋 Settings & list cleanup
* 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).
🔧 Fixes & refactors
* 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.
Full Changelog: [https://github.com/usertour/usertour/compare/v0.7.1...v0.7.2](https://github.com/usertour/usertour/compare/v0.7.1...v0.7.2)
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_ENDED` as a content end reason.
* Added `banner_end_reason` and `resource_center_end_reason` event attributes.
* Bumped `@usertour/sdk` from `0.6.4` to `0.6.6`.
**Full Changelog**: [https://github.com/usertour/usertour/compare/v0.7.0...v0.7.1](https://github.com/usertour/usertour/compare/v0.7.0...v0.7.1)
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 `externalId` columns 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_STARTED` support.
* 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.
**Full Changelog**: [https://github.com/usertour/usertour/compare/v0.6.0...v0.7.0](https://github.com/usertour/usertour/compare/v0.6.0...v0.7.0)
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 `company` to `project` across multiple areas.
* Improved rule builder behavior, state handling, and styling.
* Fixed date-fns-tz type issues.
* Upgraded server dependencies to address related advisories.
**Full Changelog**: [https://github.com/usertour/usertour/compare/v0.5.0...v](https://github.com/usertour/usertour/compare/v0.5.0...v)
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.
***
### 🧩 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 **`findOrCreateBizSession` plus 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](https://github.com/usertour/usertour/compare/v0.4.9...v0.5.0)
## 📦 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 like `leading-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](https://github.com/usertour/usertour/compare/v0.4.8...v0.4.9)
# 🚀 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.js` now has a handy `disableEvalJs` method
* 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
**Full Changelog**: [https://github.com/usertour/usertour/compare/v0.4.7...v0.4.8](https://github.com/usertour/usertour/compare/v0.4.7...v0.4.8)
## 🚀 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 **`` 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.
**Full Changelog**: [https://github.com/usertour/usertour/compare/v0.4.6...v0.4.7](https://github.com/usertour/usertour/compare/v0.4.6...v0.4.7)
# Content startup
Source: https://docs.usertour.io/concepts/content-startup
How Usertour decides whether and when to show each content type — flows, checklists, banners, resource centers, launchers, and event trackers.
Every time the SDK connects, the user changes pages, an attribute updates, or a button action fires, Usertour evaluates whether to show some content. The decision works differently for each content type. This page maps out those decisions in plain English.
For the broader picture — events, completion, and how sessions end — see [Session lifecycle](/concepts/session-lifecycle).
## How to read these diagrams
Most content types follow a "first match wins" decision tree with three paths tried in priority order:
| Path | When it applies |
| ---------------------------- | ------------------------------------------------------------------------------------- |
| **A. Explicit start** | Something asked for a specific content id (SDK call, URL parameter, button action). |
| **B. Resume the active one** | The user already has a session for this content type — reuse it. |
| **C. Auto-pick** | Neither A nor B applies. Usertour scans eligible content and tries a fallback ladder. |
Each diagram also has a **concurrency badge** at the top that tells you the model:
* `MAX 1 ACTIVE` — only one active session at a time per user; re-creatable after end (Flow, Checklist).
* `MAX 1 EVER` — only one session ever per user; never re-starts after end (Banner, Resource Center).
* `MANY CONCURRENT` — multiple sessions active in parallel (Launcher).
* `NO SESSION` — no session model at all (Event Tracker).
## Flows
Flows follow the standard A → B → C decision tree. After ending, a flow can be re-created when start conditions match again, but only one flow shows on screen at a time per user.
## Checklists
Checklists follow the same A → B → C structure as flows, with one important nuance: completion (`CHECKLIST_COMPLETED`) is not the end. The checklist stays visible until the user dismisses it.
## Banners
Banners are stricter: once a user has dismissed a banner, it never shows for that user again. The auto-pick ladder only has two steps — banners don't support "sticky to recent" (no recent session to re-attach to) and the editor hides wait timers for one-shot content.
## Resource Centers
Resource Centers use the same `MAX 1 EVER` rule as banners, with the same auto-pick ladder of two steps (no sticky-to-recent, no wait timer). The user can open and close the panel many times within one session — those are events, not new sessions.
## Launchers
Launchers break the "first match wins" pattern. Every launcher whose audience matches the user is evaluated independently and can become active in parallel.
## Event Trackers
Event trackers don't create sessions. The server distributes the tracker definitions to the SDK once; the SDK evaluates conditions locally and reports the configured event when they fire.
## Common questions
### Why doesn't my flow start even though the audience matches?
Most likely the user already has an active flow session — only one flow shows at a time. Either end the active session first, or use an explicit `usertour.start(flowId)` to switch.
### Why won't my banner show up again after I close it?
Banners are `MAX 1 EVER`. Once a user has any ended session for the banner, no new session is created. To test repeatedly, use a different user or remove the existing session via the API.
### Can two flows show at the same time?
No. Only one flow renders on screen per user. If a new flow starts while another is active, the previous flow's UI is cleaned up first. Launchers are the only content type where multiple sessions run in parallel.
### When does a wait timer fire?
When the auto-pick ladder finds a flow or checklist whose start condition is true *except* for a delay (e.g. `5 seconds after page load`). The SDK schedules the timer and re-runs the ladder when it fires. If conditions still hold then, the content starts. Wait timers are only available for flows and checklists — the editor hides this option for banners, Resource Centers, and launchers.
# Session lifecycle
Source: https://docs.usertour.io/concepts/session-lifecycle
Understand when Usertour creates, updates, completes, and ends sessions for flows, checklists, launchers, banners, resource centers, and event trackers.
A session is Usertour's record of one user's interaction with one piece of content. It connects the user, company, content, version, events, answers, progress, and current state into a single timeline.
Sessions share a common state machine: every session starts in `state = 0` (active) and transitions to `state = 1` (ended) when a terminal event is recorded. What differs between content types is how many sessions a user can have at once, and whether a new session can ever be created after the previous one ends.
For the decision tree behind *when* each type starts (start triggers, audience checks, fallback ladders), see [Content startup](/concepts/content-startup).
## The mental model
Every session-creating content type follows the same five-phase lifecycle:
1. Usertour evaluates whether the content should be available for the user (rules, manual trigger, URL match, button action, programmatic call).
2. Usertour creates or reuses a session for the matching content version. The session starts in `state = 0`.
3. The SDK receives the session and renders the content. A start event is recorded.
4. User actions and SDK signals create activity events on the session while it is active.
5. A terminal event closes the session. Usertour writes the terminal event with an end-reason attribute and flips the session to `state = 1`.
The session object is the durable record. The widget on screen is only the current rendering of that session.
## Session terms
| Term | What it means |
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Started | Usertour created or reused a session and recorded the content's start event. |
| Seen | The content was actually displayed to or encountered by the user. For flows, this is tracked at the step level. |
| Completed | The user reached the content's success condition, such as a flow completion step or all checklist tasks being completed. Completion is a milestone event, not a session terminator. |
| Ended or dismissed | The session is closed with a terminal event. After this the session is in `state = 1` and cannot accept further events. |
| Active | The session is in `state = 0` and is still available to render or update. Active does not always mean visible at this exact moment. |
Completed and ended are not the same thing. A checklist can be completed (`CHECKLIST_COMPLETED`) and still remain visible until the user dismisses it (`CHECKLIST_DISMISSED`). A flow can record `FLOW_COMPLETED` and later record `FLOW_ENDED` with a different end reason.
## Concurrency models
Usertour content types fall into one of four concurrency models. This is the most important distinction to understand, because it controls when (and whether) a new session can be created for the same user and content.
| Model | What it means | Content types |
| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- |
| max 1 ever | A user can only ever have one session for this content. Once that session ends, no new session is created for the same user and content again. | Banner, Resource Center |
| max 1 active | A user can have at most one active session at a time. After the current session ends, the user is eligible for a new session when start conditions match again. | Flow, Checklist |
| many concurrent | A user can have multiple active sessions for the same content type at the same time. Sessions are tracked independently. | Launcher |
| no session | No session object is created. Events are recorded directly against the content. | Event Tracker |
The `max 1 ever` model is enforced server-side: once the user has any ended session for a Banner or Resource Center, that content is excluded from the list of content the SDK can start for them again.
## Summary by content type
| Type | Concurrency | Start event | Activity events | End event |
| --------------- | ---------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | --------------------------- |
| Flow | max 1 active | `FLOW_STARTED` | `FLOW_STEP_SEEN` `FLOW_STEP_COMPLETED` `FLOW_COMPLETED` question answers | `FLOW_ENDED` |
| Checklist | max 1 active | `CHECKLIST_STARTED` | `CHECKLIST_SEEN` `CHECKLIST_HIDDEN` `CHECKLIST_TASK_CLICKED` `CHECKLIST_TASK_COMPLETED` `CHECKLIST_COMPLETED` | `CHECKLIST_DISMISSED` |
| Banner | max 1 ever | `BANNER_SEEN` | — | `BANNER_DISMISSED` |
| Resource Center | max 1 ever | `RESOURCE_CENTER_STARTED` | `RESOURCE_CENTER_OPENED` `RESOURCE_CENTER_CLOSED` `RESOURCE_CENTER_CLICKED` | `RESOURCE_CENTER_DISMISSED` |
| Launcher | many concurrent | `LAUNCHER_SEEN` | `LAUNCHER_ACTIVATED` | `LAUNCHER_DISMISSED` |
| Event Tracker | no session | — | — | — |
## Flows
A flow session represents the user's progress through a step-by-step experience.
**Concurrency:** `max 1 active`. A user has at most one active flow session at a time. Once it ends, a new flow session can be created when start conditions match again.
When a flow starts, Usertour creates or reuses an active session and records `FLOW_STARTED`. If the flow starts on a known step, Usertour also records `FLOW_STEP_SEEN` for that step. As the user moves through the flow, each viewed step updates the session's current step and progress.
The SDK keeps only one active flow on screen. If a new flow session is activated while another flow is active, the previous flow UI is cleaned up and the new session is rendered. If the same session is activated again, the SDK updates the existing UI instead of creating a duplicate.
A flow can record `FLOW_COMPLETED` when the user reaches the configured completion point. This is a milestone, not a session terminator — the session is still in `state = 0` after `FLOW_COMPLETED`.
The session closes when Usertour records `FLOW_ENDED`, with an end reason such as `USER_CLOSED`, `ACTION_DISMISS`, `TOOLTIP_TARGET_MISSING`, `ADMIN_ENDED`, or `END_FROM_PROGRAM`. After `FLOW_ENDED` the session is in `state = 1`.
## Checklists
A checklist session represents the user's progress through a persistent task list.
**Concurrency:** `max 1 active`. A user has at most one active checklist session at a time. Once it ends, a new checklist session can be created when start conditions match again.
When a checklist starts, Usertour records `CHECKLIST_STARTED` and sends the session to the SDK. The checklist can be expanded, collapsed, hidden, or shown without creating a new session — those changes are recorded as `CHECKLIST_SEEN` and `CHECKLIST_HIDDEN` events on the same session. Task clicks and task completions are recorded as `CHECKLIST_TASK_CLICKED` and `CHECKLIST_TASK_COMPLETED`.
When all visible tasks are completed, Usertour can record `CHECKLIST_COMPLETED`. This marks the checklist as completed but does not close the session. The session closes only when the checklist is dismissed, which records `CHECKLIST_DISMISSED` and flips state to `1`.
If a flow opens while a checklist is visible, the SDK may collapse the checklist during the flow and expand it again after the flow is unset. This is a display behavior, not a new checklist session.
## Banners
A banner session represents a message that is shown to the user until it is dismissed.
**Concurrency:** `max 1 ever`. After a user has any ended session for a banner, Usertour will not create another session for the same user and banner content again.
When a banner is shown, Usertour records `BANNER_SEEN`. When the user dismisses it (or an admin ends it via the analytics dashboard), Usertour records `BANNER_DISMISSED` with an end-reason attribute and flips state to `1`.
Banners do not have a separate completion event. Dismissal is also the completion point — the same terminal event both closes the session and marks the banner interaction as complete.
The `max 1 ever` rule is enforced server-side. After the first session ends, the banner is excluded from the list of content the SDK can start for that user.
## Resource Centers
A Resource Center session represents the user's access to the Resource Center, not every individual open or close action of the panel.
**Concurrency:** `max 1 ever`. Like banners, after a user has any ended session for a Resource Center, Usertour will not create another session for the same user and Resource Center content again.
When the Resource Center becomes available for the user, Usertour records `RESOURCE_CENTER_STARTED` and sends the session to the SDK. The user can then open and close the panel many times — those interactions are recorded as `RESOURCE_CENTER_OPENED` and `RESOURCE_CENTER_CLOSED` events on the same session.
Clicks inside the Resource Center, such as selecting a block or item, are recorded as `RESOURCE_CENTER_CLICKED`. The session closes only when the Resource Center is dismissed, which records `RESOURCE_CENTER_DISMISSED` and flips state to `1`.
The `max 1 ever` rule for Resource Centers works the same way as for banners. Opening and closing the panel does not create new sessions.
## Launchers
A launcher session represents a contextual entry point — a hotspot, icon, or button attached to an element.
**Concurrency:** `many concurrent`. Multiple launcher sessions can be active for the same user at the same time. Usertour manages launchers by content ID, so adding the same launcher again updates the existing launcher session instead of rendering a duplicate.
When a launcher is available and shown, Usertour records `LAUNCHER_SEEN`. When the user activates it (typically a click), Usertour records `LAUNCHER_ACTIVATED`. This usually means the launcher did its job: opened a tooltip, started a flow, or ran another configured action.
The launcher session closes when the launcher is dismissed, which records `LAUNCHER_DISMISSED` with an end reason such as `USER_CLOSED` (user closed it directly), `LAUNCHER_DEACTIVATED` (the tooltip closed and the launcher is configured to "dismiss after first activation"), or `ADMIN_ENDED` (admin ended via the dashboard). Removing a launcher from the SDK is keyed on the content ID, not only the session ID, because several launcher entries may coexist.
## Event Trackers
Event trackers are content, but they do not create content sessions.
The server sends tracker definitions to the SDK. The SDK evaluates the tracker conditions locally. When a condition changes from inactive to active, the SDK reports the configured event directly via the `TRACK_TRACKER_EVENT` message. Usertour records a business event with the tracker content ID and version ID, but there is no content session object to start, complete, or end — and no `endSession` API call applies.
This is why event trackers appear in analytics as events, not as content sessions.
## End reasons
All terminal events (`FLOW_ENDED`, `CHECKLIST_DISMISSED`, `BANNER_DISMISSED`, `RESOURCE_CENTER_DISMISSED`, `LAUNCHER_DISMISSED`) carry an end-reason attribute. The values currently emitted by Usertour are:
| Reason | When it fires |
| ------------------------ | --------------------------------------------------------------------------- |
| `USER_CLOSED` | User dismissed the content |
| `CLOSE_BUTTON_DISMISS` | User clicked an X / close button |
| `BACKDROP_DISMISS` | User clicked a modal backdrop |
| `DISMISS_BUTTON` | User clicked a "Dismiss" text button |
| `ACTION_DISMISS` | A configured button action triggered dismissal |
| `TRIGGER_DISMISS` | A `StepTrigger` condition triggered dismissal |
| `AUTO_DISMISSED` | The content auto-dismissed itself |
| `TOOLTIP_TARGET_MISSING` | A tooltip's target element disappeared from the page |
| `ADMIN_ENDED` | An admin ended the session via the analytics dashboard |
| `END_FROM_PROGRAM` | An SDK programmatic call ended the session |
| `UNPUBLISHED_CONTENT` | The content version was unpublished |
| `LAUNCHER_DEACTIVATED` | A launcher's tooltip closed and "dismiss after first activation" is enabled |
| `STORE_NOT_FOUND` | The session's store was missing |
## Common questions
### Why do I see a completed session that still looks active?
Completion means the success condition happened. It does not always mean the UI was dismissed. This is common for checklists: all tasks can be completed (`CHECKLIST_COMPLETED`) while the checklist remains available until the user closes it (`CHECKLIST_DISMISSED`).
### Why did a flow not start again?
The most common reasons are:
* The user already has an active session for that flow (`max 1 active`).
* The flow's start rule is configured to start only once and the user already saw it.
* The published version or targeting rules no longer match the current user.
* The content was unpublished, ending the session with `UNPUBLISHED_CONTENT`.
### Why won't a banner or Resource Center show again?
Banners and Resource Centers are `max 1 ever` content. Once a user has any ended session for that content, Usertour will not create a new session for the same user and content. To make it show again, the user's existing session would have to be removed, or the content would have to change.
### Why can I have multiple launchers but not multiple flows?
Flows, checklists, banners, and Resource Centers are singleton-style content in the SDK — only one active session of each type renders at a time. Launchers are `many concurrent` content, so several launcher sessions can be active on the same page.
### Does refreshing the page create a new session?
No. If Usertour finds an active session for the same user and content, it reuses that session. A new session is created only when the previous one has ended and the content's concurrency model and start rules allow it.
### What should I use the Content Sessions API for?
Use the [Content Sessions API](/api-reference/content-sessions/model) when you need the durable timeline of a user's interaction with a content item: progress, completion, answers, user, company, content, and version. For event trackers, query the corresponding events instead, because trackers do not create content session objects.
# Content Security Policy
Source: https://docs.usertour.io/developers/csp
Content Security Policy about Usertour.
If your web app uses Content Security Policy (CSP), you’ll need to ensure that your policy allows Usertour.js requests.
Your policy must allow the following:
```raw theme={null}
connect-src:
https://api.usertour.io
https://js.usertour.io
wss://api.usertour.io
script-src:
https://js.usertour.io
style-src:
https://js.usertour.io
img-src:
https://js.usertour.io
https://assets.usertour.io
media-src:
https://assets.usertour.io
https://js.usertour.io
```
# Identity Verification
Source: https://docs.usertour.io/developers/identity-verification
Prove that identify() and group() calls really come from your app, so nobody can impersonate your users with your public environment token.
Your environment token ships in the page source of every visitor — it has to, so the Usertour SDK can connect. That means the token alone can't prove *who* is calling. Without identity verification, anyone who has seen your page could call `usertour.identify()` with any user ID and read or overwrite that user's attributes.
Identity verification closes this: your **backend** signs an **identity token** (a JWT) for each user with a **signing secret** only you hold, and Usertour rejects identity claims without a valid token.
Key ideas, up front:
* **The token is minted on your server**, never in frontend code. The signing secret must stay as private as a password.
* **One token carries both proofs**: the `sub` claim proves "this is really user X"; the optional `companyId` claim proves "user X is a member of company Y".
* **Enforcement is opt-in per environment.** Until you turn it on, unsigned traffic keeps working while Usertour measures your signed-traffic coverage — so you can roll out safely.
* **Anonymous users are exempt from signing** — `usertour.identifyAnonymous()` involves no server round-trip, so it can't be signed; anonymous IDs are generated by the SDK and can't collide with your real user IDs. Note that anonymous users cannot call `group()` under enforcement.
## How identity tokens work
An identity token is a standard JWT, signed with **HS256** using your environment's signing secret (the full `utv_…` string is the key):
| Claim | Required | Meaning |
| ----------- | -------- | ------------------------------------------------------------------ |
| `sub` | Yes | The user ID you pass to `usertour.identify()` |
| `companyId` | No | The company ID you pass to `usertour.group()` |
| `exp` | No | Standard JWT expiry — enforced when present, bounding token replay |
Sign `sub` and `companyId` as **JSON strings**, even when your IDs are
numeric — `{"sub": "12345"}`, not `{"sub": 12345}`. JSON parsing loses
precision on 64-bit integers (snowflake-style IDs), so a numeric claim can
never be matched reliably.
```js Node.js theme={null}
const jwt = require('jsonwebtoken');
const secret = process.env.USERTOUR_SIGNING_SECRET;
const identityToken = jwt.sign(
{ sub: userId, companyId: companyId }, // omit companyId if you don't use group()
secret,
{ algorithm: 'HS256' },
);
```
```python Python theme={null}
import os
import jwt # PyJWT
secret = os.environ["USERTOUR_SIGNING_SECRET"]
identity_token = jwt.encode(
{"sub": user_id, "companyId": company_id},
secret,
algorithm="HS256",
)
```
```ruby Ruby theme={null}
require "jwt"
secret = ENV["USERTOUR_SIGNING_SECRET"]
identity_token = JWT.encode(
{ sub: user_id, companyId: company_id },
secret,
"HS256"
)
```
```php PHP theme={null}
use Firebase\JWT\JWT;
$secret = getenv('USERTOUR_SIGNING_SECRET');
$identityToken = JWT::encode(
['sub' => $userId, 'companyId' => $companyId],
$secret,
'HS256'
);
```
```go Go theme={null}
import "github.com/golang-jwt/jwt/v5"
secret := []byte(os.Getenv("USERTOUR_SIGNING_SECRET"))
identityToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub": userID,
"companyId": companyID,
}).SignedString(secret)
```
Render the token into the page (or serve it from an endpoint) alongside the user's ID, then pass it to the SDK.
Adding an `exp` claim is optional. When present, Usertour rejects the token
after it expires — a leaked token stops being replayable. If you set one,
refresh it from your backend before it expires and hand it to the SDK via
`usertour.updateUser(attributes, { token })` or
`usertour.updateGroup(attributes, { token })` — the SDK applies it to the
connection immediately, including reviving a connection that was rejected
with an expired token.
## Step 1: Generate a signing secret
Go to **Settings → Identity Verification**. Each environment has its own secrets — production and staging are isolated, so a leaked staging secret never endangers production.
Click **Generate secret** and copy the value (it looks like `utv_…`) into your backend configuration. You can reveal it again later from the same page. Use the full string, including the `utv_` prefix, as the HS256 signing key.
## Step 2: Pass the token to the SDK
Add the token as the third argument of `identify()`; when you use `group()`, mint the token with the matching `companyId` claim:
```js theme={null}
usertour.init('YOUR_ENVIRONMENT_TOKEN');
await usertour.identify(
'user-42',
{ name: 'Ada', email: 'ada@example.com' },
{ token: identityTokenFromYourBackend },
);
await usertour.group(
'acme-inc',
{ name: 'Acme Inc.' },
{ token: identityTokenFromYourBackend }, // its companyId claim must be 'acme-inc'
);
```
Nothing else changes — attributes, membership, and all other SDK calls work exactly as before. Deploy this and let it run.
**Loader version:** if you install via the [usertour.js npm
package](https://www.npmjs.com/package/usertour.js), upgrade to **0.0.24 or
later** — earlier versions don't have the `token` option in their TypeScript
types. If you install via the HTML snippet, the snippet forwards the option
as-is, but make sure you're using the current snippet from the [installation
guide](/developers/usertourjs-installation) or your Settings → Installation
page.
To check a token you've generated, paste it into **Validate a token** on the Identity Verification settings page — it reports whether the token verifies, and if not, exactly why (expired, wrong signature, missing `sub`).
## Step 3: Watch coverage, then enforce
Back in **Settings → Identity Verification**, the **Signed traffic** card shows what share of `identify()` and `group()` calls over the last 7 days carried a valid identity token. Anonymous users are listed separately — they can never be signed and don't count against coverage.
When both rows read **100%**, turn on **Require identity verification**. From then on:
* `identify()` without a valid identity token is rejected, and Usertour does not load for that user.
* `group()` without a matching `companyId` claim is rejected.
* Anonymous users keep working, but they cannot be associated with a company.
Enabling enforcement while some of your pages still send unsigned identities
will make Usertour stop loading for those users. Always confirm coverage is
at 100% first.
## Rotating a secret
Each environment can hold **two** active secrets at once — one in use, one rotation slot — and tokens signed with either are accepted. To rotate:
1. Click **Rotate secret** to create the new secret.
2. Switch every backend signer to the new secret.
3. Watch the old secret's **Last used** time on the settings page. Once it goes stale, no signer still depends on it.
4. **Revoke** the old secret.
While enforcement is on, the last active secret cannot be revoked — that would instantly sign out every verified user.
## What identity verification does and doesn't protect
With enforcement on, your environment token stops being a write credential: nobody can impersonate your users, mass-create fake users, or attach users to companies they don't belong to.
It deliberately does **not** cover:
* **Published content is still readable** with the token — it's the same content served to your anonymous visitors.
* **A real, signed-in user can still change their own attributes.** Tokens prove identity, not the truthfulness of attribute values.
* **Anonymous junk users** can still be created; they can't touch any real user's data.
# disableEvalJs()
Source: https://docs.usertour.io/developers/usertourjs-reference/advanced/disable-eval-js
Disable Evaluate JavaScript actions for security
In the flow builder, Usertour supports **Evaluate JavaScript** actions on buttons, triggers, and other elements. When a user interacts with these elements (e.g. clicks a button), the configured JavaScript runs in your app’s context. This lets you run custom code from flows without changing your app code.
If your security or compliance requirements forbid executing arbitrary JS from the flow builder, you can turn this feature off with `disableEvalJs()`.
## When to use
* You need to prevent any JavaScript defined in the flow builder from running in your app.
* Your security policy or CSP restricts dynamic script execution.
* You want to reduce the attack surface of flows (e.g. in high‑security or locked‑down environments).
## Requirements
* Call `disableEvalJs()` **before** `usertour.identify()` (or any other call that starts showing flows).
* Note that it has to be called on every page load to have an effect.
## Parameters
None.
## Example
```javascript theme={null}
import usertour from 'usertour.js';
usertour.init('');
// Disable Evaluate JavaScript actions (must be before identify)
usertour.disableEvalJs();
usertour.identify('', {
name: '',
email: '',
});
```
## Notes
* Once disabled, any **Evaluate JavaScript** actions in your flows will not run. Buttons and triggers will still work; only the custom JS execution is disabled.
* This does not affect other flow behavior (navigation, dismiss, etc.).
* If you need this restriction on all pages, add the call in a shared bootstrap/entry script that runs before flow-related code on every load.
# registerCustomInput()
Source: https://docs.usertour.io/developers/usertourjs-reference/advanced/register-custom-input
Teach Usertour to recognize custom HTML elements as input fields
Sometimes you need to use fancy UI components like combo boxes, custom dropdowns, or other non-standard input elements in your app. By default, Usertour only knows how to read values from standard `` elements. That's where `registerCustomInput()` comes in handy.
This method tells Usertour how to treat any HTML element as a text input. You can register multiple custom input types by calling this function multiple times—just point each one at a different CSS selector.
## When to use
* You're using custom UI components (combo boxes, rich selects, etc.) that aren't built with standard `` tags
* You want to create flow conditions based on the values in these custom components
* You need Usertour to capture data from non-standard input elements
## Parameters
A valid CSS selector that matches your custom input element(s). Make it
specific enough to target exactly the elements you want.
An optional function that extracts the current value from your custom input
element. It receives the matched element as an argument and should return a
string. If you don't provide this function, Usertour will just use the
element's text content as the value.
## Examples
### Basic usage with text content
Let's say you have a combo box built with divs. Here's the HTML structure:
```html theme={null}
Apple
▼
```
Tell Usertour to treat it like an input:
```javascript theme={null}
usertour.registerCustomInput(".combo-box-value");
```
Now you can create flow conditions checking if the fruit equals "Apple" or whatever value is displayed.
### Advanced usage with custom getValue
For more complex components where the value isn't directly in the text content, you can provide your own value extractor:
```html theme={null}
📍
```
Extract the value however you need:
```javascript theme={null}
usertour.registerCustomInput(".location-picker", (element) => {
// Grab the nested input's value
const input = element.querySelector("input");
return (input && input.value) || "";
});
```
### Using data attributes
You might store the actual value in a data attribute while displaying something different:
```html theme={null}
EngineeringDesign
```
```javascript theme={null}
usertour.registerCustomInput(".tag-selector", (el) => {
return el.dataset.selectedTags || "";
});
```
## Notes
* Call `registerCustomInput()` early in your initialization code, ideally right after `usertour.init()` but before flows start appearing
* You can register as many custom input types as you need—one selector per call
* The `getValue` function should always return a string, even if it's an empty string
* If your custom inputs update dynamically, make sure the value extraction logic stays accurate
* This works great with conditional flow logic in the Flow Builder—you can now branch based on these custom input values
# setBaseZIndex()
Source: https://docs.usertour.io/developers/usertourjs-reference/advanced/set-base-zIndex
Set the base z-index for Usertour floating elements
The `setBaseZIndex()` method sets the minimum z-index value for all Usertour floating elements. By default, Usertour places its elements at or slightly above z-index 1000000. Due to layer stacking, some elements may have z-index values several thousand units higher.
> **Note**: You don't always need to change the base z-index. For specific components like launchers, checklists, and the resource center, you can adjust their individual z-index values through:
>
> * Launchers: Advanced settings
> * Checklists and Resource Center: Theme settings
## Parameters
The minimum z-index value for Usertour floating elements. Must be higher than your app's highest z-index to ensure Usertour elements appear above your content.
## Returns
A `Promise` that resolves when the z-index is updated.
## Example
```javascript theme={null}
// Set a higher base z-index for Usertour elements
usertour.setBaseZIndex(2000000);
```
## Notes
* Default z-index is 1000000
* Some elements may have higher z-index values due to layer stacking
* Use this method if Usertour elements appear below your app's content
* Consider adjusting individual component z-index values first
* Higher z-index values may affect performance
# setCustomNavigate()
Source: https://docs.usertour.io/developers/usertourjs-reference/advanced/set-custom-navigate
Configure custom navigation behavior for single-page applications
By default, Usertour performs full page reloads when executing "Navigate to page" actions using `window.top.open(url, '_self')`.
Use this function to override the default navigation behavior and integrate with your application's routing system.
This is particularly useful for **Single Page Applications (SPAs)** that use client-side routing libraries like React Router, Vue Router, or Angular Router.
## Parameters
A navigation function that accepts a URL string parameter. Pass `null` to restore default behavior.
## Usage Examples
### Basic Implementation
```javascript theme={null}
// Override with your router's navigation method
usertour.setCustomNavigate(url => myRouter.push(url))
```
### React Router Integration
#### React Router v6+ (Recommended)
```javascript theme={null}
// App.js
import { BrowserRouter } from 'react-router-dom'
import { useNavigate } from 'react-router-dom'
function App() {
const navigate = useNavigate()
// Configure Usertour to use React Router navigation
usertour.setCustomNavigate(url => navigate(url))
return (
// Your app components
)
}
// Wrap your app
ReactDOM.render(
,
document.body
)
```
#### React Router v5 (Legacy)
```javascript theme={null}
// history.js
import { createBrowserHistory } from 'history'
export default createBrowserHistory()
```
```javascript theme={null}
// App.js
import { Router } from 'react-router-dom'
import history from './history'
import App from './App'
ReactDOM.render(
,
document.body
)
```
```javascript theme={null}
// usertour-config.js
import history from './history'
// Configure Usertour to use React Router navigation
usertour.setCustomNavigate(url => history.push(url))
```
### Vue Router Integration
```javascript theme={null}
// For Vue Router v4
import { useRouter } from 'vue-router'
const router = useRouter()
usertour.setCustomNavigate(url => router.push(url))
// For Vue Router v3
usertour.setCustomNavigate(url => this.$router.push(url))
```
### Angular Router Integration
```javascript theme={null}
// For Angular Router
import { Router } from '@angular/router'
constructor(private router: Router) {
usertour.setCustomNavigate(url => this.router.navigateByUrl(url))
}
```
### Next.js Integration
```javascript theme={null}
// For Next.js
import { useRouter } from 'next/router'
const router = useRouter()
usertour.setCustomNavigate(url => router.push(url))
```
### TanStack Router Integration
```javascript theme={null}
// For @tanstack/react-router
import { createRouter, RouterProvider } from '@tanstack/react-router'
import { useNavigate } from '@tanstack/react-router'
// Define your routes
const router = createRouter({
routes: [
{
path: '/',
component: HomePage,
},
{
path: '/dashboard',
component: DashboardPage,
},
],
})
// In your main App component
function App() {
const navigate = useNavigate()
// Configure Usertour to use TanStack Router navigation
usertour.setCustomNavigate(url => navigate({ to: url }))
return (
// Your app components
)
}
// Wrap your app with RouterProvider
ReactDOM.render(
,
document.body
)
```
## Notes
* The custom navigation function receives the target URL as a string parameter
* Ensure your navigation function handles both relative and absolute URLs appropriately
* Call `setCustomNavigate(null)` to revert to default page reload behavior
# setCustomScrollIntoView()
Source: https://docs.usertour.io/developers/usertourjs-reference/advanced/set-custom-scroll-into-view
Customize how Usertour scrolls elements into view
When Usertour shows a tooltip that points to an element outside the current viewport, it needs to scroll that element into view. This method lets you take control of exactly how that scrolling happens.
By default, Usertour uses smooth scrolling and only scrolls when the target element is actually outside the viewport. But maybe you want different behavior—like always centering elements, or using a custom scroll animation library. That's what `setCustomScrollIntoView()` is for.
## When to use
* You want to customize the scroll behavior when Usertour shows tooltips or highlights elements
* You need to integrate with a custom scroll library or animation framework
* You want different positioning (e.g., always center elements vertically instead of just bringing them into view)
* Your app has custom scroll containers or non-standard scrolling behavior
## Parameters
A function that receives a DOM element and handles scrolling it into view. The
function should perform whatever scroll logic you want. Pass `null` to reset
to Usertour's default scroll behavior.
## Examples
### Always center elements vertically
Maybe you want tooltip targets to always appear in the vertical center of the viewport for a consistent experience:
```javascript theme={null}
usertour.setCustomScrollIntoView((element) => {
element.scrollIntoView({
behavior: "smooth",
block: "center",
});
});
```
This ensures every highlighted element appears right in the middle of the screen, rather than just barely in view.
### Using a custom scroll library
If you're using a scroll animation library, you can integrate it here:
```javascript theme={null}
usertour.setCustomScrollIntoView((element) => {
// Using a hypothetical smooth-scroll library
smoothScroll(element, {
duration: 800,
easing: "easeInOutCubic",
offset: -100, // Add some top padding
});
});
```
### Reset to default behavior
If you want to go back to Usertour's built-in scrolling:
```javascript theme={null}
usertour.setCustomScrollIntoView(null);
```
### Compare with default behavior
For reference, here's how the default behavior works internally:
```javascript theme={null}
// This is what Usertour does by default
usertour.setCustomScrollIntoView((element) => {
element.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
});
```
The `block: 'nearest'` option means it only scrolls enough to bring the element into view, without unnecessary movement.
## Notes
* Call this method early in your initialization, ideally right after `usertour.init()`
* Your custom function should handle the actual scrolling—Usertour won't do any additional scrolling after calling your function
* The element passed to your function is always the DOM element that Usertour wants to highlight or point to
* If your scroll function is async or takes time, Usertour will continue immediately—make sure your timing works well with tooltip animations
* Consider viewport padding and navigation bars when implementing custom scroll logic
* Test with elements at different viewport positions to ensure consistent behavior
# setLinkUrlDecorator()
Source: https://docs.usertour.io/developers/usertourjs-reference/advanced/set-link-url-decorator
Automatically modify external link URLs in Usertour flows
Sometimes your flows need to link to external pages—like help articles, documentation, or knowledge base content. But what if those links need authentication tokens, tracking parameters, or other dynamic data added to them? That's where `setLinkUrlDecorator()` comes in.
This method lets you intercept and modify every external URL before Usertour renders it as a link. Every time a user clicks a link in a flow, your decorator function runs first to add whatever parameters or modifications you need.
## When to use
* Your knowledge base or help docs require authentication tokens in the URL
* You need to add tracking parameters to outbound links (e.g., `utm_source`, `ref`)
* External links need user-specific data (like user IDs or session tokens)
* You want to redirect links through a proxy or wrapper URL
* Links need to be localized or modified based on user context
## Parameters
A function that receives a URL string and returns the modified/decorated URL.
The function is called for every external link Usertour needs to render. Pass
`null` to stop decorating URLs and use them as-is.
## Examples
### Add authentication token to knowledge base links
Let's say your knowledge base requires users to pass an auth token for automatic login:
```javascript theme={null}
// TODO: Get this token from your auth system
const authToken = getKnowledgeBaseToken();
usertour.setLinkUrlDecorator((url) => {
// Only add the token to your knowledge base domain
if (url.startsWith("https://docs.myapp.com/")) {
const parsed = new URL(url);
parsed.searchParams.set("auth", authToken);
return parsed.toString();
}
return url;
});
// Original URL:
// https://docs.myapp.com/articles/getting-started
// Decorated URL:
// https://docs.myapp.com/articles/getting-started?auth=abc123xyz
```
### Add tracking parameters to all external links
Track which users click external links from your flows:
```javascript theme={null}
usertour.setLinkUrlDecorator((url) => {
const parsed = new URL(url);
parsed.searchParams.set("utm_source", "usertour");
parsed.searchParams.set("utm_medium", "in-app-flow");
parsed.searchParams.set("user_id", currentUserId);
return parsed.toString();
});
// Original URL:
// https://blog.example.com/feature-announcement
// Decorated URL:
// https://blog.example.com/feature-announcement?utm_source=usertour&utm_medium=in-app-flow&user_id=12345
```
### Redirect links through a proxy
Route all external links through your own proxy server:
```javascript theme={null}
usertour.setLinkUrlDecorator((url) => {
// Only proxy external links, not internal ones
if (!url.startsWith(window.location.origin)) {
return `https://proxy.myapp.com/redirect?url=${encodeURIComponent(url)}`;
}
return url;
});
// Original URL:
// https://external-site.com/page
// Decorated URL:
// https://proxy.myapp.com/redirect?url=https%3A%2F%2Fexternal-site.com%2Fpage
```
### Add user-specific parameters conditionally
Different logic for different domains:
```javascript theme={null}
usertour.setLinkUrlDecorator((url) => {
const parsed = new URL(url);
// Add auth token to docs
if (url.includes("docs.myapp.com")) {
parsed.searchParams.set("token", getUserToken());
}
// Add locale to support site
if (url.includes("support.myapp.com")) {
parsed.searchParams.set("locale", getUserLocale());
}
// Add plan info to upgrade links
if (url.includes("myapp.com/upgrade")) {
parsed.searchParams.set("current_plan", getUserPlan());
}
return parsed.toString();
});
```
### Remove the decorator
To stop modifying URLs and use them as originally configured:
```javascript theme={null}
usertour.setLinkUrlDecorator(null);
```
## Notes
* Call this method early in your initialization, ideally right after `usertour.init()`
* Your decorator function is called for **every** external link Usertour renders, so keep it fast
* Always check the URL domain/path before modifying—you usually don't want to modify every single link
* The function should always return a valid URL string
* Remember to handle both HTTP and HTTPS URLs if needed
* If your auth tokens expire, you may need to re-call this method with a fresh token
* Internal navigation within your app (via `setCustomNavigate`) is not affected by this decorator
* Be careful with sensitive data—these URLs may be visible in the browser's address bar or network logs
# setUrlFilter()
Source: https://docs.usertour.io/developers/usertourjs-reference/advanced/set-url-filter
Filter sensitive data from URLs before sending to Usertour
Usertour automatically tracks the current page URL and uses it for flow conditions and analytics. By default, it sends the complete URL—including domain, path, query parameters (`?...`), and fragment (`#...`)—to Usertour's servers.
If your URLs contain sensitive information like session tokens, user IDs, or secret keys, you can filter them out before they leave the browser. This method lets you sanitize URLs while still keeping the tracking and conditional logic working properly.
## When to use
* Your URLs contain sensitive data (tokens, secrets, PII) that shouldn't be sent to external servers
* You want to normalize URLs by removing tracking parameters or temporary identifiers
* You need to comply with privacy requirements that restrict what URL data can be transmitted
* You want cleaner analytics by stripping unnecessary query parameters
## Parameters
A function that receives the current URL string and returns a
filtered/sanitized version. The function should return a string representing
the cleaned URL. Pass `null` to reset to Usertour's default behavior (sending
the full URL unmodified).
## Examples
### Remove all query parameters
The simplest approach—strip everything after the `?` to remove all query parameters:
```javascript theme={null}
usertour.setUrlFilter((url) => {
const parsed = new URL(url);
parsed.search = "";
return parsed.toString();
});
// Full URL:
// https://example.com/dashboard?q=secret&sort_by=name
// Filtered URL:
// https://example.com/dashboard
```
This is great if you don't need any query parameters for flow conditions and want maximum privacy.
### Remove specific sensitive parameters
Maybe you need some query parameters but want to strip out specific ones like tokens or session IDs:
```javascript theme={null}
usertour.setUrlFilter((url) => {
const parsed = new URL(url);
parsed.searchParams.delete("token");
parsed.searchParams.delete("session_id");
parsed.searchParams.delete("api_key");
return parsed.toString();
});
// Full URL:
// https://example.com/app?token=abc123&view=grid&session_id=xyz
// Filtered URL:
// https://example.com/app?view=grid
```
This keeps useful parameters like `view` while removing sensitive authentication data.
### Remove only a specific parameter
If you just need to filter out one problematic parameter:
```javascript theme={null}
usertour.setUrlFilter((url) => {
const parsed = new URL(url);
parsed.searchParams.delete("debug");
return parsed.toString();
});
// Full URL:
// https://app.example.com/projects?debug=true&filter=active
// Filtered URL:
// https://app.example.com/projects?filter=active
```
### Hash sensitive parameters instead of removing them
Sometimes you want to keep the structure but anonymize the values:
```javascript theme={null}
usertour.setUrlFilter((url) => {
const parsed = new URL(url);
// Replace user ID with a hash, keeping flow logic working
if (parsed.searchParams.has("user_id")) {
const userId = parsed.searchParams.get("user_id");
parsed.searchParams.set("user_id", `hashed_${btoa(userId).slice(0, 8)}`);
}
return parsed.toString();
});
```
### Reset to default behavior
To go back to sending the full URL:
```javascript theme={null}
usertour.setUrlFilter(null);
```
## Verifying your filter works
After setting up your URL filter, here's how to verify it's working:
1. Visit a page in your app with a filtered URL (e.g., one with sensitive query parameters)
2. Go to Usertour's dashboard → **Users** section
3. Find your own user and click to view details
4. In the **Activity feed**, find a recent event (like **Flow Started**)
5. Expand the event and check the **Page URL** attribute—it should show your filtered URL, not the original
## Notes
* Call this method early in your initialization, right after `usertour.init()` and before any flows appear
* Your filter function runs every time Usertour needs to report the current URL
* The filtered URL is used both for flow conditions and analytics tracking
* Make sure your filter doesn't break URL-based flow conditions you've already set up
* The function should always return a valid URL string
* Consider performance—this function may be called frequently, so keep it lightweight
* The original URL in the browser address bar remains unchanged; only the URL sent to Usertour is filtered
# group()
Source: https://docs.usertour.io/developers/usertourjs-reference/companies/company
Associate the current user with a company and update company attributes
The `group()` method associates the currently identified user with a company and optionally updates the company's attributes. Call this method once on any page where the user is working with a specific company. When the user switches to a different company, call `usertour.group(newGroupId)` again to update the association.
When you provide `attributes`, they will be merged with the company's existing attributes in Usertour. Attributes not included in the call will retain their current values.
## Parameters
The unique identifier for the company in your system.
Company attributes to update. See [Attributes](/developers/usertourjs-reference/overview#attributes) for details. These attributes can be used in flow content and conditions to personalize the user experience.
Membership attributes to update for the user's relationship with the company. These attributes describe the user's role or position within the company (e.g., "role", "department", "join\_date"). They can be used in the Usertour UI just like company attributes.
An identity token: a JWT minted by your backend, HS256-signed with your
environment's signing secret, whose `sub` claim equals the current user's ID
and whose `companyId` claim must equal `groupId`. Proves the user's
membership of this company; it supersedes the token supplied to
`identify()`. Required once **Require identity verification** is enabled
for the environment. See [Identity Verification](/developers/identity-verification).
## Returns
A `Promise` that resolves when the group association is complete.
## Examples
### Basic Usage
```javascript theme={null}
// Associate user with a company and update attributes
usertour.group('comp_123456', {
name: 'Acme Corp',
industry: 'Technology',
employee_count: 500
}, {
membership: {
role: 'admin',
department: 'engineering',
join_date: '2023-01-01T00:00:00.000Z'
}
});
```
### With Identity Verification
```javascript theme={null}
// The token is a JWT your backend signs with the environment's
// signing secret: { sub: '', companyId: 'comp_123456' }
usertour.group('comp_123456', {
name: 'Acme Corp'
}, {
membership: { role: 'admin' },
token: identityTokenFromYourBackend
});
```
## Notes
* Call this method when a user starts working with a specific company
* Call again when the user switches to a different company — with identity verification, mint a fresh token whose `companyId` claim matches the new company
* Company attributes are merged with existing values
* Membership attributes describe the user's relationship with the company
* All attributes can be used in flow conditions and content
* With identity verification enforced, calls without a valid `options.token` are rejected — see [Identity Verification](/developers/identity-verification)
# updateGroup()
Source: https://docs.usertour.io/developers/usertourjs-reference/companies/update-company
Update attributes for the currently associated company
The `updateGroup()` method updates attributes for a company that has already been associated with the current user using `usertour.group()` since the last page load.
## Parameters
Company attributes to update. See [Attributes](/developers/usertourjs-reference/overview#attributes) for details. These attributes can be used in flow content and conditions to personalize the user experience.
## Returns
A `Promise` that resolves when the update is complete.
## Example
```javascript theme={null}
// Update company attributes
usertour.updateGroup({
employee_count: 600,
last_updated: '2024-03-20T08:30:00.000Z',
subscription_tier: 'enterprise'
});
```
## Notes
* This method only works for companies associated with `usertour.group()`
* Attributes are merged with existing values
* Does not affect the company association
* Can be called multiple times to update different attributes
# endAll()
Source: https://docs.usertour.io/developers/usertourjs-reference/content/end-all
Close all active Usertour content displays for the current user
The `endAll()` method allows you to programmatically close all currently active Usertour content (including flows, checklists, and other interactive guides) for the authenticated user. When called, all visible content will be immediately dismissed from the interface.
This method is particularly useful when you need to ensure a clean slate for your users, such as when they navigate away from a specific feature or when you want to reset their interactive experience. If no content is currently active, the method will execute silently without any effect.
## Example
```javascript theme={null}
usertour.endAll();
```
This method is safe to call at any time, even if no content is currently being displayed
# isStarted()
Source: https://docs.usertour.io/developers/usertourjs-reference/content/is-started
Check if a specific Usertour content has been started
The `isStarted()` method allows you to check whether a specific Usertour content has been started. Currently, this method supports checking the status of flows and checklists. This method returns a boolean value indicating the content's start status.
## Parameters
String, required - The unique identifier of the content. You can find this ID in the content detail page URL: `/env/{envId}/{contentType}/{contentId}/detail`
## Return
`boolean` - Returns `true` if the content has been started, `false` if it hasn't been started yet.
## Example
### Basic Usage
```javascript theme={null}
const isContentStarted = usertour.isStarted('cmaw8v1ch013s147h0uw8aha5');
if (isContentStarted) {
console.log('Content has been started');
} else {
console.log('Content has not been started yet');
}
```
Make sure to call `init()` before using any other Usertour.js methods
# start()
Source: https://docs.usertour.io/developers/usertourjs-reference/content/start
Programmatically trigger and display Usertour content for authenticated users
The `start()` method allows you to programmatically trigger and display Usertour content (such as flows, checklists, or other interactive guides) for your authenticated users. When called, the specified content will be rendered immediately in your application.
This method is particularly useful for implementing custom triggers for your user onboarding or feature discovery flows. For instance, you could add a "Replay Tutorial" button in your app's settings, allowing users to revisit your onboarding experience at any time.
## Parameters
String, required - The unique identifier of the content you want to display. You can find this ID in the content detail page URL: `/env/{envId}/{contentType}/{contentId}/detail`
Optional configuration object to customize the content display behavior.
When set to true, the content will only be shown to users who haven't seen it before. If the user has already viewed the content, nothing will happen. Defaults to false.
When set to true, the content will resume from where the user left off in their previous session. If false, the content will start from the beginning. Defaults to false.
When `once: true` is set, the `continue` option will not take effect since the content will only be shown once to the user.
## Return
Returns a `Promise` that resolves when the content has been successfully initialized and displayed.
## Example
### Basic Usage
```javascript theme={null}
usertour.start('cmaw8v1ch013s147h0uw8aha5');
```
### Show Content Only Once
```javascript theme={null}
usertour.start('cmaw8v1ch013s147h0uw8aha5', {
once: true
});
```
### Resume Previous Session
```javascript theme={null}
usertour.start('cmaw8v1ch013s147h0uw8aha5', {
continue: true
});
```
Make sure to call `init()` before using any other Usertour.js methods
# Events
Source: https://docs.usertour.io/developers/usertourjs-reference/events/overview
Track custom product events for users and companies in Usertour
Events let you record meaningful product actions from your application and use them in analytics, segmentation, and conditions.
A tracked event is always associated with the current user. If the user has also been associated with a company through [`group()`](/developers/usertourjs-reference/companies/company), the event can also be associated with that company.
Typical use cases include:
* Measuring product adoption
* Triggering flows or checklists based on behavior
* Building segments from user actions
* Recording milestone actions like `project_created` or `subscription_upgraded`
## Available methods
* [`track()`](/developers/usertourjs-reference/events/track) records a custom event for the current user, with optional event attributes and tracking options.
# track()
Source: https://docs.usertour.io/developers/usertourjs-reference/events/track
Record custom product events for the current user and optionally attach event attributes
The `track()` method sends a custom event to Usertour for the currently identified user.
Use it when you want to record meaningful product actions from your app, such as a project being created, a subscription being upgraded, or a teammate being invited.
Before calling `track()`, the user must already be identified with [`identify()`](/developers/usertourjs-reference/users/identify) or [`identifyAnonymous()`](/developers/usertourjs-reference/users/identify-anonymous).
If the event name or event attributes do not exist yet in Usertour, their definitions are created automatically the first time they are received.
## Parameters
The event name to track. This maps to the event's `codeName` in Usertour. We recommend using stable, descriptive names such as `project_created`, `subscription_upgraded`, or `teammate_invited`.
Optional event attributes to store alongside the event. These can be used later in analytics and event-based conditions.
Optional settings that control how the event is associated in Usertour.
By default, if the current user has also been associated with a company through [`group()`](/developers/usertourjs-reference/companies/company), the tracked event is linked to both the user and the current company. Set `userOnly: true` to record the event only for the user.
## Returns
A `Promise` that resolves when the event has been accepted by Usertour.
## Examples
### Track a simple product event
```javascript theme={null}
usertour.track('workspace_created');
```
### Track an event with attributes
```javascript theme={null}
usertour.track('subscription_upgraded', {
plan_name: 'growth',
billing_cycle: 'annual',
amount: 299
});
```
### Track a user-level event even when a company is set
```javascript theme={null}
usertour.track(
'profile_email_updated',
{
source: 'settings'
},
{
userOnly: true
}
);
```
## Notes
* Call `identify()` or `identifyAnonymous()` before using `track()`
* Event names should stay stable over time so analytics and conditions remain reliable
* If `group()` has been called, events are associated with the current company unless `userOnly: true` is set
* Event and event-attribute definitions are created automatically when first received
# Installation
Source: https://docs.usertour.io/developers/usertourjs-reference/installation
# Installing Usertour.js
Usertour.js is a lightweight JavaScript library (\~20 kB) that loads asynchronously, ensuring it won't impact your page load performance. Follow these simple steps to integrate it into your web application.
## Installation Methods
You can install Usertour.js in two ways:
1. **For modern web applications** using module bundlers (Webpack, Rollup, etc.)
* [NPM Installation](#npm-installation)
2. **For traditional web applications** or Google Tag Manager
* [HTML Snippet Installation](#html-snippet-installation)
If you're using a self-hosted UserTour instance, you'll need additional configuration to connect Usertour.js to your own server. See our [Self-Hosted Usertour.js Guide](/open-source/usertourjs) for detailed setup instructions.
### NPM Installation
We recommend installing Usertour.js using the [usertour.js npm package](https://www.npmjs.com/package/usertour.js).
First, run this in your Terminal:
```bash theme={null}
npm install usertour.js
```
Then import and initialize it in your code:
```javascript theme={null}
import usertour from 'usertour.js';
// Initialize the Usertour SDK with your environment token
usertour.init('USERTOUR_TOKEN');
// Identify the current user with their attributes
usertour.identify('USER_ID', {
name: 'USER_NAME',
email: 'USER_EMAIL',
signed_up_at: 'USER_SIGNED_UP_AT',
});
```
### HTML Snippet Installation
Add this script to your HTML file just before the closing `` tag:
```html theme={null}
!function(){var e="undefined"==typeof window?{}:window,r=e.usertour;if(!r){var t="https://js.usertour.io/",n=null;r=e.usertour={_stubbed:!0,load:function(){return n||(n=new Promise((function(r,o){var s=document.createElement("script");s.async=!0;var i=e.USERTOURJS_ENV_VARS||{};"es2020"===(i.USERTOURJS_BROWSER_TARGET||function(e){for(var r=[[/Edg\//,/Edg\/(\d+)/,80],[/OPR\//,/OPR\/(\d+)/,67],[/Chrome\//,/Chrome\/(\d+)/,80],[/CriOS\//,/CriOS\/(\d+)/,100],[/Safari\//,/Version\/(\d+)/,14],[/Firefox\//,/Firefox\/(\d+)/,74]],t=0;t=i)return"es2020";break}}return"legacy"}(navigator.userAgent))?(s.type="module",s.src=i.USERTOURJS_ES2020_URL||t+"es2020/usertour.js"):s.src=i.USERTOURJS_LEGACY_URL||t+"legacy/usertour.iife.js",s.onload=function(){r()},s.onerror=function(){document.head.removeChild(s),n=null;var e=new Error("Could not load Usertour.js");console.warn(e.message),o(e)},document.head.appendChild(s)}))),n}};var o=e.USERTOURJS_QUEUE=e.USERTOURJS_QUEUE||[],s=function(e){r[e]=function(){var t=Array.prototype.slice.call(arguments);r.load(),o.push([e,null,t])}},i=function(e){r[e]=function(){var t,n=Array.prototype.slice.call(arguments);r.load();var s=new Promise((function(e,r){t={resolve:e,reject:r}}));return o.push([e,t,n]),s}},u=function(e,t){r[e]=function(){return t}};s("disableEvalJs"),s("init"),s("off"),s("on"),s("registerCustomInput"),s("reset"),s("setBaseZIndex"),s("setSessionTimeout"),s("setTargetMissingSeconds"),s("setCustomInputSelector"),s("setCustomNavigate"),s("setCustomScrollIntoView"),s("setInferenceAttributeFilter"),s("setInferenceAttributeNames"),s("setInferenceClassNameFilter"),s("setScrollPadding"),s("setServerEndpoint"),s("setShadowDomEnabled"),s("setPageTrackingDisabled"),s("setUrlFilter"),s("setLinkUrlDecorator"),s("openResourceCenter"),s("closeResourceCenter"),s("toggleResourceCenter"),s("showResourceCenterLauncher"),s("hideResourceCenterLauncher"),i("endAll"),i("group"),i("identify"),i("identifyAnonymous"),i("start"),i("track"),i("updateGroup"),i("updateUser"),u("isIdentified",!1),u("isResourceCenterOpen",!1),u("isStarted",!1)}}();
// Initialize the Usertour SDK with your environment token
usertour.init('USERTOUR_TOKEN');
// Identify the current user with their attributes
usertour.identify('USER_ID', {
name: 'USER_NAME',
email: 'USER_EMAIL',
signed_up_at: 'USER_SIGNED_UP_AT',
});
```
## Configuration
In the code you copy-pasted above, replace **USERTOUR\_TOKEN** with the Usertour.js Token you find under [Settings -> Environments](https://app.usertour.io/project/1/settings/environments). Note that if you have multiple environments (e.g. Production and Staging) that each environment has a unique token.
Next, replace **USER\_ID** with the currently signed in user's ID in your database. Also replace **USER\_NAME**, **USER\_EMAIL** and **USER\_SIGNED\_UP\_AT** with the user's real, dynamic values. signed\_up\_at should be specified in ISO 8601 format. Example: 2019-12-11T12:34:56Z.
The properties in usertour.identify()'s second argument are all optional. They're useful for looking up users in Usertour to e.g. see their flow progress, or to use in the flow content or conditions. If you don't want to share this with Usertour, feel free to leave out the argument completely.
## Additional Features
### Custom Attributes
You can send custom user attributes to Usertour.js for more personalized experiences. These can include:
* User roles
* Preferences
* Custom metrics
* Any other relevant user data
### Anonymous Users
For public pages where users aren't logged in, use `usertour.identifyAnonymous()` instead of `usertour.identify()`. This automatically generates a unique ID for anonymous users.
[Learn more about anonymous user identification →](/developers/usertourjs-reference/users/identify-anonymous)
# Introduction
Source: https://docs.usertour.io/developers/usertourjs-reference/overview
Introduction to Usertour.js SDK and its core features
## Introduction
Usertour.js is a lightweight client-side SDK that enables you to:
* Identify and track users in your web application
* Display interactive content (flows) to your users
* Collect user behavior data for targeting
[Follow us on Twitter](https://x.com/usertourio) for the latest updates and announcements.
## Core Methods
Here are the most commonly used methods:
| Method | Description |
| ---------------------------------------------------------------------- | ---------------------------------------------- |
| [usertour.init()](/developers/usertourjs-reference/setup/init) | Initialize the SDK with your Usertour.js Token |
| [usertour.identify()](/developers/usertourjs-reference/users/identify) | Identify and track a user |
| [usertour.group()](/developers/usertourjs-reference/companies/company) | Associate a user with a company/group |
| [usertour.track()](/developers/usertourjs-reference/events/track) | Record a custom event for the current user |
## Installation
### Recommended: NPM Installation
We recommend using the npm package for the best development experience:
```bash theme={null}
# Using npm
npm install usertour.js
# Or using Yarn
yarn add usertour.js
```
For alternative installation methods, see [Usertour.js Installation Guide](/developers/usertourjs-reference/installation).
If you're using a self-hosted UserTour instance, you'll need additional configuration to connect Usertour.js to your own server. See our [Self-Hosted Usertour.js Guide](/open-source/usertourjs) for detailed setup instructions.
## Quick Start
Add this code to your application where you have access to user information:
```javascript theme={null}
import usertour from 'usertour.js';
// Initialize with your Usertour.js Token
usertour.init('');
// Identify the current user
usertour.identify('', {
name: '',
email: '',
signed_up_at: '2023-06-14T16:25:49Z',
});
```
### Important Notes
#### Replace Placeholders
* Replace all placeholders (e.g., ``, ``, ``, ``) with your actual values
* The `signed_up_at` timestamp should be in ISO 8601 format
#### Getting Your Token
* Find your Usertour.js Token in Settings -> Environments
* Each environment (Production, Staging, etc.) has its own unique token
* Make sure to use the correct token for your environment
## How It Works
The npm package (`usertour.js`) is a thin wrapper around the real Usertour.js, which is loaded from our CDN. This design allows you to interact with the imported usertour object immediately. When you make your first method call, the real Usertour.js will be automatically loaded (asynchronously) and injected into the current page. All method calls are automatically queued by the usertour object until the SDK is ready.
The core SDK is optimized for performance:
* Small footprint (less than 16 KB gzipped)
* Loads UI components only when needed
* Supports both modern and legacy browsers
If you've installed Usertour.js via a `
```
### 3. Configure URLs
If you're not using the default port (8011) or host (localhost), update the URLs in the configuration:
* `WS_URI`: WebSocket connection URL
* `ASSETS_URI`: Base URL for SDK assets — the **serve root**: the bundle appends
its own `///css/index.css` path. If you serve a locally
built `apps/sdk/dist` directly, do NOT point this at the version or target
folder — the doubled path 404s the CSS and widgets render invisibly (0×0)
with no console error.
* `USERTOURJS_ES2020_URL`: URL for the modern JavaScript bundle
* `USERTOURJS_LEGACY_URL`: URL for the legacy JavaScript bundle
* `USERTOURJS_BROWSER_TARGET` (optional): `"es2020"` or `"legacy"` — force a
bundle instead of the loader's user-agent detection; only needed when the
detection guesses wrong (unusual webviews)
### 4. Next Steps
After completing both the basic installation and self-hosted configuration, you can:
1. Initialize Usertour.js in your application
2. Configure user properties and events
3. Create and deploy content to your users
For detailed implementation guides, see our [developer documentation](/quickstart).
# Getting Started
Source: https://docs.usertour.io/quickstart
Get started with Usertour - a powerful platform for building product tours, checklists, and launchers. Follow this guide to integrate Usertour into your application and create your first user experience.
Usertour helps you create engaging product tours, checklists, and launchers to guide your users through your application. With our platform, you can create and manage user experiences independently while maintaining full control over the implementation.
## How Usertour Works
Usertour integrates with your application through a lightweight JavaScript SDK. This SDK enables:
* User identification and tracking
* Dynamic content delivery based on your settings
* Seamless display of tours, checklists, and launchers
* Real-time user behavior tracking
## Getting Started
Follow these steps to implement Usertour in your application:
### 1. Create an Account
1. Visit [app.usertour.io/auth/signup](https://app.usertour.io/auth/signup)
2. Complete the registration process
3. Verify your email address
### 2. Install Usertour.js
The Usertour SDK is the foundation of our platform. It enables:
* Display of interactive content (tours, tooltips, checklists, launchers)
* User property and event tracking
* Real-time content delivery
* Seamless integration with your application
For detailed installation instructions, see our [Installation Guide](/developers/usertourjs-reference/installation).
### 3. Configure Your Theme
Customize your user experiences to match your application's design:
1. Go to [app.usertour.io/auth/signin](https://app.usertour.io/auth/signin)
2. Navigate to [Settings → Themes](https://app.usertour.io/project/1/settings/themes)
3. Customize:
* Colors and typography
* Button styles and animations
* Modal and tooltip designs
* Overall visual consistency
### 4. Create Your First Flow
1. Follow our [Creating Your First Flow](/building-experiences/creating-your-first-flow) guide
2. Design your user experience
3. Configure targeting rules
4. Test and publish your flow
## Need Help?
* **Documentation**: Explore our [comprehensive guides](/developers/usertourjs-reference/overview)
* **Support**: Contact us at [support@usertour.io](mailto:support@usertour.io)
* **Community**: Join our [Discord community](https://discord.gg/WPVJPX8fJh) for discussions and updates
# Analyze Your Onboarding with AI
Source: https://docs.usertour.io/use-cases/analyze-onboarding-with-ai
Five prompts that turn live onboarding data into rankings, funnels, checklist friction reports and survey readouts — no dashboards, no exports.
Building your onboarding is [half the loop](/build-onboarding-with-ai) — the
other half is knowing what works. The MCP server exposes the same per-type
analytics the dashboard uses (flow funnels, per-task checklist data,
per-question survey breakdowns, session history), so an AI assistant can read
the numbers **and** explain them: rank content, find the drop-off step, cluster
survey verbatims into themes.
The video below runs five analysis prompts against live data:
Analysis needs **no write access**. Connect the assistant with **Read-only**
on the OAuth consent (or a read-scoped token) and it can do everything on
this page but change nothing — the grant enforces what the prompts request.
See [read-only by default](/api-reference-v2/mcp#read-only-by-default-prompt-injection-safety).
These prompts deliberately ask for more than any product tracks and instruct
the assistant to say which metrics are unavailable — so you get an honest
report of what the data supports, not invented numbers. Run them as-is, or trim
them to the metrics you care about.
## 1 — Portfolio overview: everything live, in one table
```text theme={null}
Use the Usertour MCP to retrieve performance data for all active flows,
checklists, announcements, surveys, and Resource Center content from the
last 30 days.
Present the results in a concise table with:
* Content name and type
* Number of users reached
* Starts or impressions
* Completion rate
* Dismissal rate
* Average completion time, if available
* Primary CTA conversion rate
* Trend compared with the previous 30 days
Highlight the strongest and weakest experiences. Clearly state the
reporting period, sample size, and any metrics that are unavailable.
Do not change any content.
```
## 2 — Effectiveness ranking: top three, bottom three
```text theme={null}
Use the Usertour MCP to analyze all onboarding content from the last
30 days and rank it by effectiveness.
Evaluate each experience using:
* Reach
* Engagement rate
* Completion rate
* Drop-off rate
* CTA conversion
* Contribution to activation, when activation events are available
Identify the top three and bottom three experiences. Explain the likely
reasons behind their performance using only available data. Separate
confirmed findings from hypotheses.
```
## 3 — Flow funnel: find the drop-off step
Replace `[FLOW NAME]` with one of your flows:
```text theme={null}
Use the Usertour MCP to analyze the flow named [FLOW NAME] over the
last 30 days.
Build a step-by-step funnel showing:
* Users who entered each step
* Step-view rate
* Next-step conversion rate
* Drop-off rate
* Average time spent on each step
* Final completion rate
Identify the step with the largest meaningful drop-off. Recommend
specific changes to the copy, CTA, targeting, or step structure, but
do not modify the flow.
```
## 4 — Checklist friction: which tasks stall adoption
```text theme={null}
Use the Usertour MCP to analyze the User List onboarding checklist from
the last 30 days.
For every checklist item, report:
* Number of users who started it
* Number and percentage who completed it
* Manual versus automatic completions, if available
* Median time to completion
* Related flow launch and completion rates
* The most common completion order
Identify the checklist items that contribute most to activation and the
items that create the most friction. Recommend whether any items should
be reordered, rewritten, merged, or removed. Do not make changes.
```
## 5 — Survey readout: from responses to opportunities
```text theme={null}
Use the Usertour MCP to analyze responses from the product feedback
survey.
Provide:
* Total invitations, starts, submissions, and completion rate
* Average and median rating for each rated feature
* Rating distribution
* Overall satisfaction score
* Most frequently praised features
* Most common usability problems
* Recurring feature requests
* Major themes from open-ended responses
* Representative short excerpts, with personal information removed
Group feedback into positive, neutral, and negative themes. Do not
invent sentiment scores or conclusions when the sample size is too
small.
Finish with the five most important product opportunities, ranked by
user impact and frequency.
```
## Where to next
The other half of the loop — four prompts that ship a complete onboarding
experience.
Connect any client, see every tool, understand scopes and read-only mode.
# Let users choose between tours
Source: https://docs.usertour.io/use-cases/choose-between-two-tours
Use modal button actions to send users from one choice flow into a simple or deep tour.
Sometimes onboarding should not be one-size-fits-all.
A new user may want a short product tour. Another user may want a deeper walkthrough. Instead of trying to put both paths into one long flow, start with a small choice modal and let the user choose which tour to open next.
The clean pattern is to use one flow as the chooser, then start a different flow from each button.
## Scenario
When the user logs in, show a modal with two choices:
```text theme={null}
Simple tour
Deep tour
```
If the user clicks **Simple tour**, Usertour should close the chooser modal and start the simple tooltip flow.
If the user clicks **Deep tour**, Usertour should close the chooser modal and start the deeper walkthrough.
## Create The Three Flows
Use separate flows for each job:
```text theme={null}
Flow 1:
Choose your tour
Flow 2:
Simple tour
Flow 3:
Deep tour
```
The first flow only asks the user to choose. The other two flows contain the actual onboarding paths.
This keeps each flow easier to edit, publish, and analyze. The chooser is not mixed with the tour content, and the simple and deep paths can evolve independently.
## Configure The Buttons
On the **Simple tour** button in the chooser modal, add these actions:
```text theme={null}
Dismiss flow
Start new flow/checklist: Simple tour
```
On the **Deep tour** button, add these actions:
```text theme={null}
Dismiss flow
Start new flow/checklist: Deep tour
```
The first action closes the chooser flow. The second action starts the selected target flow.
## Why The Chooser Should Be Dismissed
The chooser modal is only a decision point. Once the user has chosen a path, it should get out of the way.
If the button only starts a new flow, the original chooser still has its own session and state. Dismissing the chooser first gives the next flow a clean moment to begin.
The sequence should be:
```text theme={null}
User clicks Simple tour.
The chooser flow is dismissed.
The Simple tour flow starts.
```
The same pattern applies to the deep tour path.
## When To Use This Pattern
Use this pattern when a user choice should send people into different onboarding flows.
Good examples include:
* Simple setup vs advanced setup
* Admin tour vs member tour
* Product tour vs integration tour
* New customer onboarding vs migration guide
* Short overview vs full walkthrough
The chooser flow should stay short. It should help the user choose a path, not explain the product itself.
## What To Avoid
Avoid building both tours as branches inside one long flow unless they are tightly connected.
A single branching flow can become hard to maintain. It also makes analytics harder to read, because the same flow contains multiple user journeys.
Separate flows are usually clearer:
```text theme={null}
One chooser flow
One simple tour
One deep tour
```
This structure makes the user's choice explicit and keeps each onboarding path focused.
# Follow up after close button
Source: https://docs.usertour.io/use-cases/close-button-follow-up
Use a second flow to show a short tooltip after a user closes or ends an onboarding flow.
A user who closes onboarding has not necessarily rejected it.
They may be in the middle of a task, or they may understand enough for now. The close button gives them control over the moment, but it can also leave one small question unanswered: where can they restart the onboarding later?
Use a second flow for this follow-up. Let the original flow end normally, then start a short tooltip that points to the place where onboarding can be reopened.
For example:
```text theme={null}
Original flow:
Welcome onboarding
Follow-up flow:
You can restart onboarding from the help menu at any time.
```
The follow-up flow should not continue the onboarding. Its job is smaller: confirm that closing was accepted, and show the user where to return.
## Configure The Follow-Up Flow
Create a second flow with one tooltip. Point that tooltip at the launcher, help menu, resource center, or any other place where the user can restart onboarding.
Then configure the second flow to auto-start from an event rule:
```text theme={null}
Event
Flow Dismissed/Ended
at least 1 time
at any point in time
by current user in any company
Where
Flow ID is your original flow ID
```
With this setup, Usertour waits until the current user has ended that specific original flow. Only then does it start the follow-up tooltip.
## Why This Uses An Event Rule
The close button is not a normal step button. It belongs to the flow session itself.
When the user clicks X, the SDK closes the flow and sends an end message for that session. The server records a **Flow Dismissed/Ended** event, including the flow ID and the reason the flow ended.
The follow-up flow uses that recorded event as its start signal. This keeps the behavior tied to the real lifecycle of the original flow, instead of treating the close button like a regular step button.
That distinction matters because the follow-up should happen after the original flow is gone. The original flow ends first; the restart reminder starts only after that ending has been recorded.
## Keep The Rule Specific
The **Flow ID** condition is what prevents this follow-up from starting after every flow the user closes.
Without it, the rule would mean:
```text theme={null}
Start this follow-up after the user has ended any flow.
```
With it, the rule means:
```text theme={null}
Start this follow-up only after the user has ended this onboarding flow.
```
That makes the follow-up predictable, especially in products that have multiple onboarding, announcement, or feature education flows.
## Frequency
In most cases, set the follow-up flow to **Once per user**.
The user only needs to learn the restart location once. Showing the same reminder every time they close onboarding can make the close button feel less respectful.
Use a broader frequency only if the restart location changes, or if the follow-up points to a temporary campaign rather than a permanent help entry.
If your product has company-specific onboarding, consider changing the scope to **by current user in current company**. That keeps the reminder tied to the user's current workspace instead of their full history across companies.
## What The User Experiences
From the user's point of view, the sequence feels simple:
```text theme={null}
They close onboarding.
The onboarding disappears.
A small tooltip points to the restart location.
```
The message should be brief:
```text theme={null}
You can restart onboarding from here anytime.
```
This keeps the close action intact while still giving the user a way back.
# Go to next step after input
Source: https://docs.usertour.io/use-cases/continue-after-text-input
Use a step trigger to continue a flow after a user fills in a textarea or input field.
Some onboarding steps depend on an action the user performs inside your product.
A common example is a textarea. The flow explains what to write, the user enters a value, and the next step should point to a checkbox or another control on the same page.
The right tool for this is a **step trigger**. The trigger watches the input while the current step is showing, then moves the flow forward when the user has filled it in.
## Scenario
Suppose the first step asks the user to describe their setup in a textarea. The next step should explain a checkbox that only makes sense after the textarea has been filled.
```text theme={null}
Step 1:
Tell us about your setup.
Step 2:
Select this checkbox if you want to enable the option.
```
If the user has to click a Next button after typing, the flow feels slower than the page itself. The better experience is for the flow to continue once the input is complete.
## Configure The Trigger
Open the step that asks the user to type, then add a trigger to that step. Select the input element the user should fill. In this example, the selected element is `#setup-notes`.
Use this configuration:
```text theme={null}
Condition
User fills in input: #setup-notes
Wait
0 seconds
Action
Go to Step: Select the checkbox
```
With this setup, the first step stays active while the user is typing. After `#setup-notes` receives a new non-empty value and the user pauses, the trigger runs the **Go to Step** action.
## Why This Works
A step trigger belongs to the current step. It watches conditions while that step is being shown. When those conditions match, it performs the configured action.
For this use case, the condition is **User fills in input**. Usertour selects the configured element, listens for input changes, and treats the field as filled only after the value has changed from its original state and is no longer empty.
The action is **Go to Step**. When the condition becomes true, Usertour shows the selected next step. In this case, that next step is the one that points to the checkbox.
The result is a flow that follows the user's work instead of asking the user to confirm something they have already done.
## When To Use This Pattern
Use this pattern when one step should continue after the user completes a field on the page.
Good examples include:
* A textarea where the user writes a description.
* A name field that must be filled before the next instruction.
* A search box where the next step explains how to choose a result.
* A form field that appears inside your own product, not inside the Usertour tooltip.
The trigger should be attached to the field that proves the user has completed the action. If the next step explains a checkbox, the trigger usually belongs to the previous input step, not to the checkbox step.
## When To Use A Different Condition
Use **User fills in input** when any new non-empty value is enough.
Use **Text input value** when the next step depends on the actual text. For example, the flow should continue only if the input contains a certain word or matches a specific value.
Use **Text input value** instead of **User fills in input** if the field may already be filled before the step appears. **User fills in input** is designed for the moment when the user enters a new value while the step is active.
Use an **Element** condition instead when the page changes after typing, such as when a search result appears and the next step should wait for that result.
## Timing
The trigger does not fire on the first keystroke.
Usertour waits until the input has changed and the user has stopped typing briefly. This avoids moving the flow forward while the user is still entering text.
If the page needs extra time to update after typing, add a short trigger wait before the **Go to Step** action. Keep it small; the wait is only there to let the page settle, not to guess how long the user will take to type.
# Restart onboarding from a button
Source: https://docs.usertour.io/use-cases/restart-onboarding-from-button
Use usertour.start() to let users replay an onboarding flow from a button in your product.
Many onboarding flows are configured to show only once per user.
That is usually the right default. A first-run tour should not keep interrupting someone every time they return to the product. But users still need a way back. They may skip the tour the first time, forget a step, or want to show the onboarding flow to a teammate later.
For this case, keep the automatic start rule as **Once per user**, and add a button in your product that manually starts the flow again.
## Scenario
Suppose your onboarding flow appears automatically the first time a user signs in.
Later, the user opens a help menu or settings page and clicks:
```text theme={null}
Restart onboarding
```
That button should start the same onboarding flow from the beginning, even if the flow has already been shown automatically before.
## Use `usertour.start()`
Add a click handler to your button and call `usertour.start()` with the flow ID:
```js theme={null}
document.querySelector("#restart-onboarding").addEventListener("click", () => {
usertour.start("your_flow_id");
});
```
The flow ID is the content ID from the flow detail page URL:
```text theme={null}
/env/{envId}/flows/{flowId}/detail
```
For example:
```text theme={null}
https://app.usertour.io/env/1/flows/cmaw8v1ch013s147h0uw8aha5/detail
```
The flow ID is:
```text theme={null}
cmaw8v1ch013s147h0uw8aha5
```
For the full API reference, see [`start()`](/developers/usertourjs-reference/content/start).
## Why This Works
The **Once per user** setting controls automatic starts. It prevents the flow from appearing repeatedly on its own.
Manual starts are different. When the user deliberately clicks a restart button, `usertour.start()` tells Usertour to show that flow now.
This gives you both behaviors:
```text theme={null}
Auto-start:
Show onboarding once, when the user first qualifies.
Manual restart:
Let the user replay onboarding when they ask for it.
```
That distinction is useful because the user is no longer being interrupted. They are choosing to reopen the tour.
## Restart Or Resume
For a normal replay button, call `usertour.start()` without extra options:
```js theme={null}
usertour.start("your_flow_id");
```
This starts the flow from the beginning.
If you want the user to continue from where they left off, use `continue: true`:
```js theme={null}
usertour.start("your_flow_id", {
continue: true,
});
```
Do not use `once: true` for a restart button. `once: true` means the flow should only show if the user has not seen it before, which is the opposite of a replay experience.
## Where To Put The Button
The best place is somewhere the user naturally looks for help:
* A help menu
* A resource center
* An onboarding checklist
* A settings or profile page
* An empty state that helps the user restart setup
The label should make the action clear:
```text theme={null}
Restart onboarding
Replay product tour
Show setup guide again
```
Avoid labels that sound like the user is starting a new account setup. The action is only replaying guidance.
## Before You Call It
Make sure Usertour is initialized and the user has been identified before the button can start the flow.
In most apps, that means the restart button should be available only after your normal Usertour setup has run:
```js theme={null}
usertour.init("your_token");
usertour.identify("user_id");
```
Once the user is identified, the restart button can call `usertour.start()` whenever the user asks to see the tour again.
# Start when element appears
Source: https://docs.usertour.io/use-cases/start-flow-when-element-appears
Use an Element is present start rule to start a flow only after the target UI is visible.
In many web applications, a route change happens before the screen is fully ready.
A user may already be on the correct page, while the button, table, panel, or empty state that a flow depends on is still loading. If the flow starts during that gap, the first step can appear too early or fail to attach to its target.
Use the **Element is present** start rule when the flow should start only after a specific UI element is visible.
## Scenario
```text theme={null}
/settings/team
```
Suppose a flow explains how to invite teammates. Its first step points to the **Invite teammate** button on the team settings page.
The page route becomes `/settings/team` first. Then the application loads permissions, team data, and feature access. Only after those checks finish does the invite button appear.
If the flow only uses a page rule, it can start as soon as the route matches:
```text theme={null}
Current page is /settings/team
```
That confirms the user is on the right page, but it does not confirm that the target UI is ready.
The safer start rule combines the page and the element:
```text theme={null}
Current page is /settings/team
AND
Element is present: Invite teammate button
```
To set the **Element is present** target, point at the button with the built-in picker instead of typing a selector. See [Selecting Elements](/how-to-guides/selecting-elements) — and define a stable selector (e.g. a `data-tour` attribute) so the rule keeps working across releases.
With this rule, the flow starts only after both conditions are true:
```text theme={null}
The user is on /settings/team
and the Invite teammate button is visible.
```
The difference is subtle, but important: the flow starts when the page is usable, not merely when the URL has changed.
## Where This Rule Fits
Use **Element is present** when the first step of a flow depends on UI that is rendered conditionally or asynchronously.
Common examples include:
* A button that appears after permissions are loaded.
* A table that appears after data is fetched.
* A feature card that appears only for eligible users.
* An empty state that appears only when the user has no records.
* A panel that appears after a tab, filter, or SPA route finishes rendering.
The rule should represent the specific UI the flow needs. It is not meant to be a generic page-loading delay.
## The Wait Setting
The wait setting is often misunderstood.
`Wait 3 seconds` does **not** mean:
```text theme={null}
Wait up to 3 seconds for the element to appear.
```
It means:
```text theme={null}
After the page rule matches
and the element is present,
keep those conditions true for 3 more seconds,
then start the flow.
```
For example, if the invite button takes 8 seconds to appear and the rule has `Wait 3 seconds`, the flow starts about 3 seconds after the button becomes visible.
This is useful when the element appears during an animation, layout shift, or slow rendering phase. In most cases, a short wait of 1-3 seconds is enough.
## Why This Works
This rule works because Usertour separates two kinds of knowledge.
The server can decide whether a user is eligible for a flow. It knows the published content, page rules, frequency settings, priority, user attributes, and historical activity.
The browser knows the live screen. It can tell whether the selected element is actually visible to the user.
For element-based rules, the SDK checks the page in the browser and reports the element state back to Usertour. Once the other start rules and the browser-side element check both match, Usertour starts the flow session.
This is why the rule is reliable in modern applications: it waits for the screen the user can actually interact with.
## If The Flow Does Not Start
When a flow using **Element is present** does not start, the issue is usually one of these:
* Is the user on the matching page?
* Is the element visible for this user's role or plan?
* Is the element inside a closed tab, menu, accordion, or modal?
* Did you select a temporary loader instead of the final UI?
* Is the flow blocked by frequency, such as "Once per user"?
The most common mistake is selecting an element that appears during loading rather than the final UI the user should interact with.
## Choosing The Element
Choose an element that proves the user can follow the first step of the flow.
Good targets include:
* The button the first tooltip points to
* A loaded table, form, or settings panel
* A stable empty state
* A feature card that only appears for eligible users
Avoid targets such as:
* Loading spinners
* Skeleton placeholders
* Toast messages
* Text that changes per user
* Elements that appear briefly and then disappear
The best target is usually a stable, ordinary piece of UI. If that element is visible, the flow has a real place to begin.