---
title: "Create a product tour"
method: POST
path: "/product-tours"
tags: ["Product Tours"]
---

# Create a product tour

`POST /product-tours`

## Request

- Base URL: `https://api.produktly.com/api/v1`
- URL: `https://api.produktly.com/api/v1/product-tours`
- Auth: HTTP bearer

Creates the tour as a draft. Pass active: true to publish immediately — the tour must have at least one step and targeting set, otherwise it stays a draft and the response note explains why. The response includes a dashboardUrl for reviewing the tour in the builder.

## Request body

- object
  - `name` string, required
  - `steps` union[]: Tour steps in order. A tour with no steps never shows
    - union
      - object
        - `type` 'highlight', required
        - `domQuery` string, required: CSS selector of the element to spotlight. Prefer stable data-* attributes
        - `direction` 'auto' | 'top' | 'right' | 'bottom' | 'left': Card placement relative to the element. Default: auto
        - `highlight` union: Spotlight cutout on/off. Default yes
          - boolean
          - 'yes' | 'no'
        - `allowClickEvents` boolean: Let the user click the highlighted element. Default true
        - `dismissOverlaysOnLeave` boolean: Fire Escape/outside-click when leaving this step (closes dropdowns the step opened)
        - `skipIfNotFound` union: Skip this step if the element never appears (after settings.timeout seconds). Default no (falls back to a centered modal)
          - boolean
          - 'yes' | 'no'
        - `expandSelectors` string[]: Extra selectors merged into the spotlight rect (e.g. portaled dropdowns)
        - `id` string: Server-generated UUID — omit on create, preserve on update (analytics key on it)
        - `title` string: Plain text (HTML is escaped). Supports {{user.attr}} templates
        - `content` string: HTML body (sanitized at render; iframes allowed). Supports {{user.attr | fallback}} templates
        - `width` integer: Card width px. Widget default: 492 welcome, 432 others
        - `height` integer: Card height px; content scrolls beyond it
        - `nextBtnText` string
        - `backBtnText` string
        - `finishBtnText` string
        - `action` 'none' | 'open_url' | 'start_tour': Optional secondary button. It never advances the tour
        - `actionText` string
        - `actionUrl` string: Required when action=open_url
        - `openActionUrlInNewTab` boolean
        - `closeTourOnAction` boolean
        - `actionTourId` integer: Required when action=start_tour
        - `actionListeners` object[]: Auto-advance when the user interacts with an element (max 1)
          - `type` 'click' | 'hover' | 'focus' | 'user_input' | 'element_visible', required
          - `domQuery` string, required: CSS selector the listener attaches to
          - `delay` number: Seconds to wait after the event before advancing. Defaults: 1 for user_input, 0.25 otherwise
          - `hideButtons` union: Hide Back/Next while waiting for the event. Default yes
            - boolean
            - 'yes' | 'no'
        - `showDarkBackground` boolean: Backdrop. Widget default: true for modal/welcome, false for toast
      - object
        - `type` 'modal', required
        - `id` string: Server-generated UUID — omit on create, preserve on update (analytics key on it)
        - `title` string: Plain text (HTML is escaped). Supports {{user.attr}} templates
        - `content` string: HTML body (sanitized at render; iframes allowed). Supports {{user.attr | fallback}} templates
        - `width` integer: Card width px. Widget default: 492 welcome, 432 others
        - `height` integer: Card height px; content scrolls beyond it
        - `nextBtnText` string
        - `backBtnText` string
        - `finishBtnText` string
        - `action` 'none' | 'open_url' | 'start_tour': Optional secondary button. It never advances the tour
        - `actionText` string
        - `actionUrl` string: Required when action=open_url
        - `openActionUrlInNewTab` boolean
        - `closeTourOnAction` boolean
        - `actionTourId` integer: Required when action=start_tour
        - `actionListeners` object[]: Auto-advance when the user interacts with an element (max 1)
          - `type` 'click' | 'hover' | 'focus' | 'user_input' | 'element_visible', required
          - `domQuery` string, required: CSS selector the listener attaches to
          - `delay` number: Seconds to wait after the event before advancing. Defaults: 1 for user_input, 0.25 otherwise
          - `hideButtons` union: Hide Back/Next while waiting for the event. Default yes
            - boolean
            - 'yes' | 'no'
        - `showDarkBackground` boolean: Backdrop. Widget default: true for modal/welcome, false for toast
      - object
        - `type` 'static', required
        - `id` string: Server-generated UUID — omit on create, preserve on update (analytics key on it)
        - `title` string: Plain text (HTML is escaped). Supports {{user.attr}} templates
        - `content` string: HTML body (sanitized at render; iframes allowed). Supports {{user.attr | fallback}} templates
        - `width` integer: Card width px. Widget default: 492 welcome, 432 others
        - `height` integer: Card height px; content scrolls beyond it
        - `nextBtnText` string
        - `backBtnText` string
        - `finishBtnText` string
        - `action` 'none' | 'open_url' | 'start_tour': Optional secondary button. It never advances the tour
        - `actionText` string
        - `actionUrl` string: Required when action=open_url
        - `openActionUrlInNewTab` boolean
        - `closeTourOnAction` boolean
        - `actionTourId` integer: Required when action=start_tour
        - `actionListeners` object[]: Auto-advance when the user interacts with an element (max 1)
          - `type` 'click' | 'hover' | 'focus' | 'user_input' | 'element_visible', required
          - `domQuery` string, required: CSS selector the listener attaches to
          - `delay` number: Seconds to wait after the event before advancing. Defaults: 1 for user_input, 0.25 otherwise
          - `hideButtons` union: Hide Back/Next while waiting for the event. Default yes
            - boolean
            - 'yes' | 'no'
        - `showDarkBackground` boolean: Backdrop. Widget default: true for modal/welcome, false for toast
      - object
        - `type` 'welcome', required
        - `id` string: Server-generated UUID — omit on create, preserve on update (analytics key on it)
        - `title` string: Plain text (HTML is escaped). Supports {{user.attr}} templates
        - `content` string: HTML body (sanitized at render; iframes allowed). Supports {{user.attr | fallback}} templates
        - `width` integer: Card width px. Widget default: 492 welcome, 432 others
        - `height` integer: Card height px; content scrolls beyond it
        - `nextBtnText` string
        - `backBtnText` string
        - `finishBtnText` string
        - `action` 'none' | 'open_url' | 'start_tour': Optional secondary button. It never advances the tour
        - `actionText` string
        - `actionUrl` string: Required when action=open_url
        - `openActionUrlInNewTab` boolean
        - `closeTourOnAction` boolean
        - `actionTourId` integer: Required when action=start_tour
        - `actionListeners` object[]: Auto-advance when the user interacts with an element (max 1)
          - `type` 'click' | 'hover' | 'focus' | 'user_input' | 'element_visible', required
          - `domQuery` string, required: CSS selector the listener attaches to
          - `delay` number: Seconds to wait after the event before advancing. Defaults: 1 for user_input, 0.25 otherwise
          - `hideButtons` union: Hide Back/Next while waiting for the event. Default yes
            - boolean
            - 'yes' | 'no'
        - `showDarkBackground` boolean: Backdrop. Widget default: true for modal/welcome, false for toast
      - object
        - `type` 'toast', required
        - `id` string: Server-generated UUID — omit on create, preserve on update (analytics key on it)
        - `title` string: Plain text (HTML is escaped). Supports {{user.attr}} templates
        - `content` string: HTML body (sanitized at render; iframes allowed). Supports {{user.attr | fallback}} templates
        - `width` integer: Card width px. Widget default: 492 welcome, 432 others
        - `height` integer: Card height px; content scrolls beyond it
        - `nextBtnText` string
        - `backBtnText` string
        - `finishBtnText` string
        - `action` 'none' | 'open_url' | 'start_tour': Optional secondary button. It never advances the tour
        - `actionText` string
        - `actionUrl` string: Required when action=open_url
        - `openActionUrlInNewTab` boolean
        - `closeTourOnAction` boolean
        - `actionTourId` integer: Required when action=start_tour
        - `actionListeners` object[]: Auto-advance when the user interacts with an element (max 1)
          - `type` 'click' | 'hover' | 'focus' | 'user_input' | 'element_visible', required
          - `domQuery` string, required: CSS selector the listener attaches to
          - `delay` number: Seconds to wait after the event before advancing. Defaults: 1 for user_input, 0.25 otherwise
          - `hideButtons` union: Hide Back/Next while waiting for the event. Default yes
            - boolean
            - 'yes' | 'no'
        - `showDarkBackground` boolean: Backdrop. Widget default: true for modal/welcome, false for toast
      - object
        - `type` 'redirect', required
        - `id` string
        - `redirectUrl` string, required: URL to navigate to. Supports {user.attr} and {currentUrl} templates. Title/content are never shown on redirect steps
  - `targeting` union: Where the tour shows. Omit for an untargeted draft; without targeting the tour can never show
    - object
      - `showOnAllPages` true, required
    - object
      - `rules` object[], required
        - `type` union, required
          - 'url' | 'current_time' | 'time_of_day' | 'day_of_week' | 'location' | 'language' | 'produktly_user_created_at' | 'device_type' | 'screen_width' | 'screen_height' | 'produktly_user_visit_count' | 'produktly_user_days_active'
          - string
          - string
        - `comparisonOperator` 'equals' | 'not_equal' | 'contains' | 'no_contains' | 'starts_with' | 'ends_with' | 'one_of' | 'not_one_of' | 'greater' | 'greater_eq' | 'less' | 'less_eq' | 'exists' | 'not_exists' | 'time_ago' | 'within_last' | 'between', required
        - `value` union
          - string
          - number
          - union[]
            - union
              - …
          - object
            - `value` integer, required
            - `unit` 'minutes' | 'hours' | 'days' | 'weeks' | 'months' | 'years', required
      - `logicalOperator` 'AND' | 'OR' | 'and' | 'or': How multiple rules combine. Default "AND".
    - object
      - `userSegmentIds` integer[], required
  - `settings` object
    - `tourStart` 'minified' | 'immediate-minified' | 'immediate' | 'manually': How the tour starts. Default "minified" (beacon). "manually" = only via Produktly.startTour()
    - `startDelay` number: Seconds before start. Default 0
    - `trigger` 'once' | 'always': Default "once"
    - `saveProgress` boolean: Resume where the user left off. Default true
    - `hideBeacon` union: Only meaningful with tourStart "manually". Default no
      - boolean
      - 'yes' | 'no'
    - `beaconPosition` 'bottom-right' | 'bottom-left'
    - `multiPageEnabled` boolean: Continue the tour across page loads. Default true
    - `timeout` number: Seconds to wait for a step's element. Default 5
    - `allowClosingTour` boolean: Show the X / allow Escape. Default true
    - `closeOnOutsideClick` boolean: Default false
    - `themeColor` string: CSS color. Overridden by an attached theme
    - `textColor` string
    - `nextBtnText` string
    - `backBtnText` string
    - `finishBtnText` string
    - `startTourBtnText` string: Beacon label. Default "Start Product Tour"
    - `continueTourBtnText` string: Default "Continue Tour"
  - `themeId` integer
  - `folderId` integer
  - `active` boolean: Publish immediately. The tour needs at least one step and targeting set; otherwise it is saved as a draft and the response note explains why.

## Response `201`

Created tour

- ProductTour
  - `id` integer, required
  - `name` string, required
  - `active` boolean, required
  - `createdAt` string, date-time, required
  - `updatedAt` string, date-time, required
  - `folderId` integer, nullable, required
  - `themeId` integer, nullable, required
  - `steps` ProductTourStep[], required
    - `type` 'highlight' | 'modal' | 'static' | 'welcome' | 'toast' | 'redirect', required
    - `id` string: Assigned by Produktly when the step is stored
  - `settings` ProductTourSettings, required: Stored settings: everything accepted on create or update, plus server-managed keys such as filterType and, for tours built in the dashboard, localisation fields.
    - `tourStart` 'minified' | 'immediate-minified' | 'immediate' | 'manually': How the tour starts. Default "minified" (beacon). "manually" = only via Produktly.startTour()
    - `startDelay` number: Seconds before start. Default 0
    - `trigger` 'once' | 'always': Default "once"
    - `saveProgress` boolean: Resume where the user left off. Default true
    - `hideBeacon` union: Only meaningful with tourStart "manually". Default no
      - boolean
      - 'yes' | 'no'
    - `beaconPosition` 'bottom-right' | 'bottom-left'
    - `multiPageEnabled` boolean: Continue the tour across page loads. Default true
    - `timeout` number: Seconds to wait for a step's element. Default 5
    - `allowClosingTour` boolean: Show the X / allow Escape. Default true
    - `closeOnOutsideClick` boolean: Default false
    - `themeColor` string: CSS color. Overridden by an attached theme
    - `textColor` string
    - `nextBtnText` string
    - `backBtnText` string
    - `finishBtnText` string
    - `startTourBtnText` string: Beacon label. Default "Start Product Tour"
    - `continueTourBtnText` string: Default "Continue Tour"
    - `filterType` string: Derived from targeting. "everywhere" when the tour shows on all pages
  - `rules` TargetingRule[], required
    - `type` string, required: Rule type, "user_segment" for segment rules, or "custom::::<attributeKey>" for a user attribute
    - `comparisonOperator` string
    - `value` unknown
    - `userSegmentId` integer: Present on "user_segment" rules
  - `ruleLogicalOperator` string, nullable, required
  - `dashboardUrl` string, required
  - `note` string

## Other responses

- `400`: Validation error
- `401`: Unauthorized
- `403`: Plan limit reached
- `429`: Rate limit exceeded

---

[API](https://skmtc.dev/produktly/apis/produktly-api.md) · [All operations](https://skmtc.dev/produktly/apis/produktly-api/llms.txt) · [OpenAPI document](https://skmtc.dev/produktly/apis/produktly-api/revisions/fd942a97a7d1?raw)
