## How it works 1. You create a workspace in the [dashboard](https://app.justonecx.com) and get a **public key**. 2. You add the SDK to your site with that key. 3. You build tours, announcements, surveys and chat in the dashboard. 4. The SDK fetches what is published for the current visitor and shows it. The public key is not a secret. It only grants access to content you have already published. See [Security](/security). --- # What is JustOneCX? JustOneCX puts five things inside your web app, in one place: tours, announcements, surveys, feature requests and chat. You connect your app once, then build everything in the dashboard. You don't need to be a developer to use it, but someone has to add one line of code to your site. ## The six things you get | Name | What it does | |---|---| | **Tours** | Step-by-step pop-ups that show users how to use a feature. | | **Announcements** | Tell users about new features right inside your app. | | **Surveys** | Ask users one or two questions at the right moment (NPS and more). | | **Roadmap & Changelog** | Users suggest and vote on ideas. You show what you're building and what you shipped. | | **Chat** | Talk to users inside your app. | | **Help center** | Questions and answers your users can read without waiting for a person. | | **Analytics** | See which steps users finish, where they drop off, and who comes back. | Two helpers appear everywhere: - **Segments**: "paying users", "signed up this week". You pick who sees what. - **Event log**: everything users did, as it happens. Use it to check your setup works. ## Three ways to set it up 1. **Do it yourself.** Open **Developers** in the dashboard, copy the snippet and paste it where your site loads. Using Webflow, Framer, Shopify, Wix, Squarespace or WordPress? See [Website builders](/install/website-builders). Then build in the visual editor. See the [Quickstart](/quickstart). 2. **Send it to your developer.** Send them the [Quickstart](/quickstart) and your **Public key** from **Developers**. The Public key is safe to share. Never send your **secret key**. 3. **Ask your AI assistant.** If you use Claude Code, Codex or Cursor in your own project, it can install JustOneCX and draft tours, announcements, surveys and What's new entries for you. You review and publish. See [Build with AI](/ai/). ## Is it working? The dashboard header shows a small status next to the page title: - **Local only**: only local addresses (like `localhost`) are allowed, so your live site can't load JustOneCX. Add your live address under **Developers → Domains & CORS**. - **Waiting for first visit**: open a page of your app with the snippet in it. - **Local only**: only `localhost` addresses are allowed. Add your live address. - **Connected**: your app is sending visits. If something doesn't show, see [Troubleshooting](/troubleshooting). ## Words you may see | Word | Meaning | |---|---| | **Public key** | Safe to share. Goes in your app's snippet (the browser). | | **Agent token** | Lets your AI tool act for you. One per person. | | **Secret key** | Never share. Only for your own server. | | **Monthly active users (MAU)** | People who used your app at least once in a month. Pricing is based on this. | | **identify** | Tell JustOneCX who is signed in, so users don't see the same thing twice and you know who said what. | | **AI assistant** | A tool such as Claude Code, Codex or Cursor that works in your project. | ## What an AI assistant can and can't do It works as you, in one workspace, and only does what your role allows. It saves drafts. Publishing, and anything that changes what visitors see straight away, needs the **Make changes live** access level, which is off by default. It can delete a tour, announcement or survey, but only into the Trash, from where you (or it) can restore it; emptying the Trash is yours. It never sees your users' personal data. It can also build funnels, set chat business hours, response-time target and saved replies, and add custom domains for your public pages (a domain only goes live after you publish its DNS records). Things it can't do, such as billing, team members, roles and keys, it points you to in the dashboard. --- # Quickstart Get a tour showing on your site in about five minutes. ## 1. Get your public key 1. Sign in at [app.justonecx.com](https://app.justonecx.com) and open your workspace. 2. Go to **Settings → Developers**. 3. Copy the **public key**. It starts with `pk_`. ## 2. Allow your site In **Settings → Developers → Domains & CORS**, add the origin of your site, for example `https://www.example.com`. For local work, add `http://localhost:3000` (or your dev port). See [Allowed origins](/guides/allowed-origins). ## 3. Add the SDK Paste this before the closing `` tag of every page, with your own key: ```html ``` The script starts itself. There is nothing else to write. Using React, Vue, Angular or another framework? See [Frameworks](/install/frameworks). Prefer npm? See [npm package](/install/npm). ## 4. Publish something 1. In the dashboard open **Tours** and create a tour. 2. Add a step and pick the element it should point at. 3. Set the tour to **Published**. ## 5. Check it works Open your site in a new browser window. The tour starts. If it does not, open [Troubleshooting](/troubleshooting). ## 6. Tell us who the user is (recommended) After a user signs in, call `identify` so you can target by plan or role: ```js window.JustOneCX.identify("user-42", { plan: "growth", role: "admin" }); ``` When the user signs out, call `reset`: ```js window.JustOneCX.reset(); ``` Before you go live, turn on [identity verification](/guides/identity-verification). ## Next steps - [API reference](/api/) - [Targeting and segments](/guides/targeting) - [Roadmap, ideas and changelog](/guides/roadmap-changelog) - [Set it up with your AI tool](/ai/) --- # Build with AI You can ask your AI coding tool to add JustOneCX for you. You say what you want. It reads our docs and edits your project. Example prompts: - "Add JustOneCX to this app. My public key is `pk_...`." - "Identify the user after login and reset on logout." - "Track a `completed_checkout` event when an order succeeds." - "Show a survey after the user finishes onboarding." There are three ways to help your AI. Start with the first. | Way | Best for | Setup | | --- | --- | --- | | [`llms.txt`](#llms-txt) | Any tool, no install | Paste a URL | | [Agent skill](/ai/skill) | Claude Code, Cursor, Codex, Copilot | Copy one file | | [MCP server](/ai/mcp) | Claude Code, Codex, Cursor, Gemini CLI: build in your workspace for you | Sign in once | ## llms.txt {#llms-txt} We publish our docs in a format made for AI tools: - [`/llms.txt`](https://docs.justonecx.com/llms.txt): an index of every page. - [`/llms-full.txt`](https://docs.justonecx.com/llms-full.txt): the full docs in one file. - Any page as Markdown: add `.md` to its URL, for example `/quickstart.md`. Tell your AI: "Read https://docs.justonecx.com/llms-full.txt and add JustOneCX to my app. My public key is `pk_...`." ## What you still do yourself - Get the public key from **Settings → Developers**. - Add your site's origin to **Domains & CORS**. - Review the change. Do not give your AI the Secret key. It belongs on your server only. --- # MCP server Connect your AI coding assistant (Claude Code, Codex, Cursor, Gemini CLI) to JustOneCX. It can then install the SDK, build product tours from your code, announce features, add surveys, answer chat, manage your roadmap, read your analytics and event log, set up funnels and Brand Kit, and find out why something doesn't show, as you. ## Connect Your assistant signs in through your browser the first time. You pick the workspace and how much it may do: - **See your content and setup**: reads only. - **Create and edit drafts** (default): nothing your users see changes until a person publishes. - **Make changes live**: can also publish. Off unless you choose it. It can never do more than your own role allows, and a workspace owner can switch AI assistants off or cap them in **Settings → Developers → AI assistants**. **Claude Code** ```sh claude mcp add --transport http justonecx https://api.justonecx.com/mcp ``` Then run `/mcp`, pick `justonecx` and sign in. The `justonecx` plugin adds the server and the [skills](/ai/skill) in one step. **Codex**: add to `~/.codex/config.toml`, then run `codex mcp login justonecx`: ```toml [mcp_servers.justonecx] url = "https://api.justonecx.com/mcp" ``` **Cursor**: `.cursor/mcp.json` ```json { "mcpServers": { "justonecx": { "url": "https://api.justonecx.com/mcp" } } } ``` **Gemini CLI**: add to `~/.gemini/settings.json`, then run `/mcp auth justonecx`: ```json { "mcpServers": { "justonecx": { "httpUrl": "https://api.justonecx.com/mcp" } } } ``` No token goes in any of these files. ## Lovable and other chat-based builders Lovable can connect to an MCP server from its chat. Open **Connectors**, scroll to the bottom and choose the **Custom MCP** card. Enter the name `justonecx` and the URL `https://api.justonecx.com/mcp`. Pick **OAuth** to sign in through your browser. If sign-in doesn't work, pick **Bearer token** and paste a token from **Settings → Developers → AI assistants**. Lovable keeps a custom connection to you, so it is never part of your published app. Other chat-based builders that accept a remote MCP server work the same way: URL, then OAuth or a bearer token. If a builder has no place for an MCP server, ask it to add the [script tag](/install/website-builders) instead. ## What it can do | Tool | What it does | | --- | --- | | `workspace_status` | Your role, plan and a setup checklist, with a fix for every failing item. | | `where_to` | The dashboard page and steps for anything without a tool (help articles, chat settings, billing, members…). | | `get_install_info` | Your Public key and the install snippet for your framework. Never the secret key. | | `segments` | Segments by `action`: `list`, `get`, `save` (create, or update with `id`), `delete` (needs `confirm`), `preview` (how many people match, before saving). Rules are the same groups and conditions as the Segments page. | | `upload_image` | Hosts one image and returns its URL for `imageUrl` in `save_content`. Send `sourceUrl` (public https, up to 5 MB) or `base64` (small). PNG, JPEG, WEBP, GIF only. | | `conversations` | The chat inbox by `action`: `list` (status, assignee, unread, waiting longest), `get` (thread plus a customer summary), `reply` (needs `confirm`, goes to the customer at once), `note` (internal), `assign`, `close`, `reopen`, `tag`. Names appear, emails and phone numbers do not. Needs "Make changes live" to write. | | `customers` | Find one person by `action`: `search`, `get` (contact details hidden unless `includePII`), `insights`, `activity`, `reset_tour_progress` (needs `confirm`), `verify_hash` (checks whether your server signs identities correctly, never shows the expected hash). | | `roadmap` | The ideas board and public roadmap by `action`: `list_ideas`, `get_idea`, `save_idea`, `set_status` (needs `confirm`: voters are emailed), `merge` (needs `confirm`: duplicate into another idea, restorable from the trash), `moderation_queue`, `moderate`, `board_settings_get`, `board_settings_update` (needs `confirm`). Commenter identity is left out. | | `funnels` | Build funnels by `action`: `get`, `save` (create, or update with `id`; 2 to 4 steps of events, tours or announcements; match any order, in sequence or strict). Read results with `analytics` (`funnel_results`). | | `analytics` | Read-only, aggregated analytics by `report`: `overview` (activation, feature adoption, stickiness, friction), `event_names` (events received in 30 days, for picking the activation event), `active_users`, `nps`, `tour_performance`, `funnels`, `funnel_results` (per-step visitors, conversion, median time; optional `segmentId`). No person ids, emails or comment text. | | `settings` | Workspace settings by `area` and `action` (`get`, `update`; updates send only the fields in `values`, support `dryRun` and ask for `confirm` because they change the live site). Areas: `theme` (colours, named styles, chat look), `custom_css` (raw CSS for the customer's site; shows the risk first, needs `confirm`, returns the previous CSS so it can be undone), `chat`, `help_center`, `identity` (verification on or off), `frequency` (announcement cap), `widget_priority`, `activation` (the aha event), `tour_skip_reasons`, `allowed_origins` (add a site; owners). | | `events` | The raw event log, read-only, by `action`: `list` (newest first; filter by `eventName`, `source`, `dateFrom`, `dateTo`; paged, up to 50 rows) and `today` (events, unique users and accounts today vs yesterday). Email, phone and address properties are hidden unless `includePII`. Use it to confirm an event really arrived. | | `trash` | Delete a tour, announcement or survey (`delete`, needs `confirm`, it leaves the site at once) and bring it back (`restore`); `list` shows what is in the Trash. Emptying the Trash is only possible in the dashboard. | | `get_content`, `save_content`, `publish_content` | Tours, announcements, surveys, What's new entries and Help center FAQs and articles, chosen with `type`. Save a draft under a key (or update by id; Help items by id), edit single tour steps with `stepOps`, read `stats`, tour `progress` and survey `responses` (answers and scores, never names or emails), publish. Help center changes need "Make changes live". | | `validate_tour`, `get_tour_preview_link` | Check a tour's selectors, page rule and step flow (`goToUrl`, event steps); preview the draft on your site for 24 hours. A live check against the running page is not available on the server: the tool says how to do it with the preview link or your own browser tool. | For a vague request such as "add a tour", the `justonecx-plan` skill makes it study your product and propose a ranked list first. It chooses where each tooltip, survey or announcement sits from where the element is on screen, and asks when unsure. `validate_tour` warns when a tooltip has no placement set. Everything it creates is a draft until a person publishes it. Changes that reach customers or the live site (a chat reply, a status change that emails voters, settings, custom CSS, deleting) need an explicit `confirm`. Contact details (email, phone, address) are hidden unless the task asks for them. It can't empty the Trash, export survey responses as a file, or change members, roles, billing or keys: for those it points you to the dashboard page and steps. Every change shows in your audit log as made by the AI assistant. ## For CI and tools without browser sign-in Create a token in **Settings → Developers → AI assistants** and send it as a bearer header. Keep the token in an environment variable, never in a config file: ```sh claude mcp add --transport http justonecx https://api.justonecx.com/mcp \ --header "Authorization: Bearer $JUSTONECX_TOKEN" ``` A token has the same limits as the browser sign-in: it is tied to you, to one workspace and to the access level you pick, and it can be revoked in the same place. ## Looking up our docs The server above acts on your workspace. To let an AI read our documentation instead, point it at [`llms.txt`](/ai/#llms-txt) or install the [agent skill](/ai/skill). Neither needs an account. --- # Agent skill The skill tells an AI coding agent exactly how to add JustOneCX to your project: how to find your framework, where to call `init`, where to call `identify`, and what to avoid. ## Install Download the skill file and put it where your tool reads instructions: | Tool | Location | | --- | --- | | Claude Code | `.claude/skills/justonecx-integration/SKILL.md` | | Cursor | `.cursor/rules/justonecx.mdc` | | Codex, Copilot, others | Append to `AGENTS.md` in your repo root | ```sh # Claude Code mkdir -p .claude/skills/justonecx-integration curl -o .claude/skills/justonecx-integration/SKILL.md https://docs.justonecx.com/ai/justonecx-integration/SKILL.md # Any tool that reads AGENTS.md curl https://docs.justonecx.com/ai/justonecx-integration/SKILL.md >> AGENTS.md ``` The file is plain Markdown. You can read it before you install it: [SKILL.md](https://docs.justonecx.com/ai/justonecx-integration/SKILL.md). ## Skills for the MCP server With the [MCP server](/ai/mcp) connected, these skills teach your assistant the full workflow (draft, check, preview, ask before publishing): | Skill | For | | --- | --- | | [justonecx-integrate](https://docs.justonecx.com/ai/skills/justonecx-integrate/SKILL.md) | Install, identify, check the setup works | | [justonecx-tours](https://docs.justonecx.com/ai/skills/justonecx-tours/SKILL.md) | Product tours from your code, with `data-jcx` anchors and a preview link | | [justonecx-announce](https://docs.justonecx.com/ai/skills/justonecx-announce/SKILL.md) | Announcements and What's new entries from a diff or PR | | [justonecx-surveys](https://docs.justonecx.com/ai/skills/justonecx-surveys/SKILL.md) | Surveys at the right moment | | [justonecx-support](https://docs.justonecx.com/ai/skills/justonecx-support/SKILL.md) | Chat inbox, customers, analytics, funnels, ideas and roadmap | | [justonecx-plan](https://docs.justonecx.com/ai/skills/justonecx-plan/SKILL.md) | Study your product and propose what to build before building | | [justonecx-doctor](https://docs.justonecx.com/ai/skills/justonecx-doctor/SKILL.md) | Why something doesn't show | The Claude Code plugin `justonecx` installs all seven plus the server. ## Use Tell your agent what you want: > Add JustOneCX to this app with public key `pk_...`. Identify the signed-in user and reset on sign-out. > Embed our roadmap and changelog in this app, with a What's new button in the header. --- # Boards API Functions the SDK exports for the roadmap, ideas and changelog. See [Embed](/guides/embed-roadmap-changelog) for use. ```ts mountRoadmap(target, options?) mountIdeas(target, options?) mountChangelog(target, options?) ``` `target` is an element or a CSS selector. Each returns a handle `{ destroy(), navigate(slug | null), on(event, callback) }`. ## Options | Option | Type | Default | Description | | --- | --- | --- | --- | | `publicKey` | `string` | from `init()` | Only needed when `init()` did not run on the page. | | `apiUrl` | `string` | from `init()` | Backend origin. | | `routing` | `"query" \| "path" \| "none"` | `"query"` | How the open item is kept in the URL. | | `basePath` | `string` | none | Path prefix for `routing: "path"`. | | `onNavigate` | `(slug \| null) => void` | none | Called when the user opens or leaves an item. | | `inheritFont` | `boolean` | `true` | Use the host page font. | | `colorScheme` | `"light" \| "dark" \| "auto"` | workspace theme | Force a scheme. | ## Panels ```ts openChangelog(options?) // What's new panel openRoadmap(options?) // roadmap panel getUnreadChangelogCount(): number onUnreadChangelogCountChange(listener): () => void ``` `options` is `{ anchor?: HTMLElement, placement?: "popover" | "sidebar" }`. See [What's new widget](/guides/whats-new-widget). ## Custom elements ``, `` and `` take `routing` and `base-path` attributes. The script tag registers them. ## Script tag attributes | Attribute | Option | | --- | --- | | `data-boards-url` | `boardsUrl` for [`init`](/api/init) | --- # identify, group, track Run these after `init()`. ## identify ```ts identify("user-42", { role: "admin", plan: "growth" }); ``` Tells JustOneCX who the current user is. Traits **merge** into what the user already has. Call it on every page load after login (not only on the login event) to keep traits fresh. The order relative to `init()` does not matter: a call made before `init()` is queued and runs when `init()` does. If identity verification is on and you don't pass `userHash`, the call is rejected and the SDK logs `identify() was rejected` in the browser console. See [Identity verification](/guides/identity-verification). ```ts function identify(userId: string, traits?: Traits, options?: { userHash?: string }): Promise; ``` The promise resolves when the backend has saved the traits. It never rejects. Await it, then call `init()` again, if you need tours that depend on the new traits to appear straight away. `options.userHash` is the HMAC signature of `userId` from your server. See [Identity verification](/guides/identity-verification). ::: warning `identify()` alone is not proof of identity. The public key is visible in your page source, so any page that has it can claim any `userId`. Turn on identity verification before you rely on identity for anything sensitive. ::: ## group {#group} ```ts group("acme-inc", { plan: "growth", seats: 12 }); ``` Sets the account (company, team, organisation) the user belongs to. ```ts function group(accountId: string, traits?: Traits, options?: { accountHash?: string }): Promise; ``` `accountHash` is the HMAC signature of `accountId`, computed the same way as `userHash`. ## track {#track} ```ts track("clicked_upgrade_cta", { plan: "growth" }); ``` Records a custom event. It is tied to whatever `identify` and `group` calls came before it. ```ts function track(eventName: string, properties?: Traits): void; ``` - Events sent before `identify` are still recorded. They have no user attached, as for any anonymous visitor. - A checklist task can wait for an event name. When `track()` sends that exact name, the task completes. - A survey can use an event name as its trigger. The next matching `track()` call shows the survey once. ## Same person, many devices The visitor id is per browser. If the same user calls `identify("user-42")` from two devices, JustOneCX links both to the same person for analytics and targeting. **Chat is the exception.** A conversation stays with the browser that started it, so a claimed identity cannot read another person's chat history. --- # API reference The SDK exports these functions. In the [script tag](/install/script-tag) build they live on `window.JustOneCX`. | Function | Purpose | | --- | --- | | [`init(options)`](/api/init) | Start the SDK. Fetches and shows published content. | | [`identify(userId, traits?, options?)`](/api/identify) | Set who the current user is. | | [`group(accountId, traits?, options?)`](/api/identify#group) | Set the account the user belongs to. | | [`track(eventName, properties?)`](/api/identify#track) | Record a custom event. | | [`reset(options?)`](/api/reset) | Clear identity on sign-out. | | [`startTour(tourId)`](/api/reset#starttour) | Replay a tour on demand. | | [`mountRoadmap`, `mountIdeas`, `mountChangelog`, `openChangelog`, `openRoadmap`](/api/boards) | Show the roadmap, ideas and changelog. | ## Rules for all calls - Run `init()` first. `identify`, `group`, `track` and `startTour` called before `init()` log a console error and do nothing. They never throw. - Every call is best effort. A failed network request never throws and never blocks your page. - Traits are plain objects of strings, numbers and booleans. ## Types ```ts interface InitOptions { publicKey: string; apiUrl?: string; disableChat?: boolean; trackRouteChanges?: boolean; } ``` `Traits` is a flat key/value object. Import it from the package: `import type { Traits } from "@justonecx/sdk"`. --- # init ```ts import { init } from "@justonecx/sdk"; await init({ publicKey: "pk_your_workspace_key", apiUrl: "https://api.justonecx.com", trackRouteChanges: true, }); ``` `init` starts the SDK. It creates an anonymous visitor id (stored in the browser's `localStorage`), fetches the content published for this visitor, and shows it. Tours, announcements and surveys load independently, so a failure in one never blocks the others. It also mounts the chat bubble. ## Options | Option | Type | Default | Description | | --- | --- | --- | --- | | `publicKey` | `string` | none (required) | Workspace public key from **Settings → Developers**. | | `apiUrl` | `string` | `http://localhost:8000` | Backend origin. Always set it on real sites. The SDK logs a warning if you do not. | | `trackRouteChanges` | `boolean` | `false` | Re-run `init()` on every client-side navigation. Use it in single-page apps. Leave it off if you already call `init()` from your router. | | `frictionSignals` | `boolean` | `true` | Set `false` to turn off the rage-click, dead-click and JS-error signals (`$rage_click`, `$dead_click`, `$js_error`). A dead click is only counted on something that looks clickable (pointer cursor, button or link role, `onclick` or a tab stop). | | `disableChat` | `boolean` | `false` | Do not mount the chat bubble. Use it when you run the SDK for your own staff, so staff do not open chats in their own product. | | `excludePaths` | `string[]` | none | Paths where the SDK shows nothing at all. `*` is one path segment, `**` any depth, for example `["/admin/**", "/preview"]`. See [Targeting](/guides/targeting#turn-the-sdk-off-on-some-pages). | | `disableTours`, `disableAnnouncements`, `disableSurveys` | `boolean` | `false` | Turn one kind of content off for this `init()` call. The others keep working. | | `boardsUrl` | `string` | next to the SDK | Where the lazily loaded `justonecx-boards.global.js` comes from. Set it when a strict CSP or a proxy needs a fixed location. See [Embed](/guides/embed-roadmap-changelog). | ## Script tag equivalents | Attribute | Option | | --- | --- | | `data-public-key` | `publicKey` | | `data-api-url` | `apiUrl` | | `data-track-route-changes="true"` | `trackRouteChanges: true` | | `data-boards-url` | `boardsUrl` | ## Calling init again Calling `init()` again is safe. A tour or announcement already on screen is not fetched and shown a second time. This is what `trackRouteChanges` does for you. ## Server-side rendering Do not call `init()` on the server. It uses `window` and `document`. Importing the package on the server is safe. --- # reset and startTour ## reset Call `reset()` when the user signs out. ```ts import { reset } from "@justonecx/sdk"; await reset(); // clear identity, start a new anonymous visitor, run init() again await reset({ remount: false }); // clear only, do not run init() again ``` Why it matters: the visitor id is a permanent id in `localStorage`. On a shared browser, the next person who signs in would otherwise inherit the previous person's open chat and other visitor state. `reset()` does these steps: 1. Removes the chat bubble and closes its connection. 2. Stops any tour that is still playing and removes launcher buttons. 3. Deletes the stored visitor id and creates a new one. 4. Runs `init()` again with the same key and URL, unless `remount` is `false` or `init()` never ran. A new chat bubble appears after `reset()`. This is by design. ## startTour {#starttour} Replay a tour from your own button, for example a "Show me around" item in a help menu. ```ts import { startTour } from "@justonecx/sdk"; startTour("export-flow"); // the tour's key, or its id ``` `startTour` plays the tour immediately. It ignores the tour's targeting, frequency cap and completion state. Pass the tour's **key** (set when an AI assistant creates the tour, e.g. `export-flow`) or its id, which is in the URL of the tour's edit page in the dashboard. Only published tours start. ## Preview links A link like `https://your-app.com/settings/export?jcx_preview=…` (from the dashboard or an AI assistant) plays that tour's current draft for 24 hours, on any page where the SDK runs. The SDK removes the token from the address bar right away. Nothing else from your workspace shows on a preview page. A tour can also show its own **persistent launcher** button. Enable it in the tour editor: **Settings** tab, **Launcher** section. The launcher calls `startTour` for you. --- # Server events Send events from your own backend when something happens that the browser does not see: a payment succeeded, a webhook fired, a job finished. Server events count toward analytics, segments and funnels like browser events. You authenticate with your **Secret key** (see [Keys](/guides/keys)), never the Public key. ## Track an event `POST /ingest/events/track` | Field | Type | Notes | |---|---|---| | `userId` | string | Your own user id, the one you pass to `identify()`. Recommended. | | `visitorId` | string | One browser's visitor id. Use it only if you have it. | | `accountId` | string, optional | Links the person to an account, like `group()`. | | `eventName` | string | For example `subscription_upgraded`. | | `properties` | object, optional | Strings, numbers, booleans or null. | Send `userId` or `visitorId`. Both are fine. With `userId`, the event goes to that person's profile across all their browsers. If the person has never opened your app, a profile is created and joins the later `identify()` for the same id. The call returns `204` and no body. ```bash curl -X POST https://api.justonecx.com/ingest/events/track \ -H "Authorization: Bearer $JUSTONECX_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{"userId": "user-123", "eventName": "subscription_upgraded", "accountId": "acme", "properties": {"plan": "pro"}}' ``` ```js // Node.js, server only await fetch(`${process.env.JUSTONECX_API_URL}/ingest/events/track`, { method: "POST", headers: { Authorization: `Bearer ${process.env.JUSTONECX_SECRET_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ userId: user.id, eventName: "invoice_paid", properties: { amount: 4900 }, }), }); ``` ## Errors | Status | Meaning | |---|---| | `400` | The body has neither `userId` nor `visitorId`, or a field is too long or the wrong type. | | `401` | The Secret key is missing, wrong or was rotated. | | `429` | Too many calls for this key (3000 per minute). Slow down and retry. | Use `identify` and `group` from the browser as usual. The server also accepts `POST /ingest/events/identify` and `POST /ingest/events/group` with the same bodies as the browser calls, with a `visitorId`. --- # Allowed origins Add each site that runs the SDK in **Settings → Developers → Domains & CORS**. Use the full origin: scheme, host and port when needed. ```text https://www.example.com https://app.example.com http://localhost:3000 ``` Embedded boards use the same list. The hosted pages and custom domains do not need an entry, because they run on our servers. If you forget a site, the browser blocks the SDK's requests and shows a CORS error in the console. ::: warning The allow-list is not authentication It only restricts browsers. A script outside a browser can send any `Origin` header. Treat the public key as public, and use [identity verification](/guides/identity-verification) to protect identities. ::: --- # Analytics definitions Every number on the Analytics page, and how it is counted. Open the info icon next to a title in the dashboard for the short version. ## What you need to send | You want | You must send | | --- | --- | | Activation | `identify()` with a user id, and your "aha" event with `track()` | | Feature adoption | Events tied to an account: `group()` or an account in `identify()`, then `track()` | | Stickiness | Any activity from identified or anonymous visitors | | Friction signals | Only the SDK on your page (it sends them itself) | | Funnels | The events you want as steps | An empty chart says which of these is missing. ## Activation - **Signup** means a person's first `identify()` call (a person with a user id). It is counted from the day we first saw them. - **New identified users** are people first seen in the chosen period who have a user id. - **Activated** are those who then sent the activation event. Events sent before they first appeared do not count. - **Activation rate** = activated / new identified users. - **Median time to value** = the middle gap between first seen and the first activation event. ### Choosing the activation event Open Analytics, find the **Activation** card and pick from the list of events your app sent in the last 30 days, with counts and when each last arrived. You can also type a name. Under the field a line shows the check for the last 24 hours, for example "Received 42 `invited_teammate` events in the last 24 h, 40 linked to users". If events arrive but none are linked to a user, call `identify()` before the event or send it from your server with a `userId`. ## Feature adoption For each event name: accounts that sent it in the period / accounts that sent any event in the period. Friction events (`$rage_click`, `$dead_click`, `$js_error`) are left out. ## Stickiness DAU, WAU and MAU are distinct active people in the last 1, 7 and 30 days. DAU/WAU and WAU/MAU show how many weekly or monthly people come back daily or weekly. ## Friction signals Counts of events the SDK sends itself: three or more fast clicks on one element (rage), clicks that change nothing (dead) and uncaught JavaScript errors. No session recording. ## Funnels A funnel has two to four steps. A step is a custom event (optionally filtered by event properties), a tour event or an announcement event. - **Step matching**: any order, sequential or strict order. - **Visitors** per step are distinct visitors who matched. - **Of previous step** is this step's visitors / the previous step's visitors. - **Median time** is the middle gap between the previous step and this one, over visitors counted at both. - **Templates**: Signup to Activation, Visit to Signup to Activation, and tour or announcement engagement. Templates use placeholder event names; pick your real ones from the list. - **Only people in segment** limits the results to one segment. Segment results look at the first 5,000 visitors who reached step 1. ## How fresh is it The overview is cached for up to 60 seconds. The page shows "Updated N s ago". Billing and plan limits never use this cache. ## From an AI assistant The MCP `analytics` tool returns the same aggregated numbers (no person ids, emails or comments). See [AI assistants](/ai/mcp). --- # Announcements An announcement is a message you show in your product: a new feature, a maintenance notice, a promotion. It appears as a modal or a banner. ## Create an announcement 1. Open **Announcements** in the dashboard and create one. 2. On the **Builder** tab, pick a format (modal, slideout, banner or changelog popover) and write the content. 3. On the **Settings** tab, set the **Schedule**, then under **Targeting** choose who sees it (everyone, or a [segment](/guides/targeting)) and how often. An **A/B test** section is there too. 4. Publish. Use **Preview** in the header to click through the announcement before it goes live. Results are on the **Results** tab. ## Behaviour - A banner is pinned to the top of the page and needs no target element. - A visitor who dismisses an announcement does not see it again. - Announcements and tours load independently. A problem with one never blocks the other. - An announcement can have a call-to-action button that opens a link or starts a tour. ## Changelog Announcements are messages inside your product. Your public changelog is separate: you write and publish its entries under **Roadmap & Changelog > Changelog**. Publishing an entry can also create a banner announcement that links to it. See [Roadmap, ideas and changelog](/guides/roadmap-changelog). ## Links Only `http` and `https` links are allowed in announcement content. Other link types are removed. --- # Sign-on link On a hosted page or custom domain the visitor is a stranger to us. A sign-on link tells the page who they are, using your Secret key. Use it to show a private board, to attribute votes to a customer and account, or to skip asking for an email. Embedded boards do not need this: they use the session from [`identify()`](/api/identify). ## How it works 1. Your server builds a short-lived signed link for the logged-in user. 2. The user opens it. We check the signature, set a first-party cookie and redirect to the same URL without the token. 3. The page now knows the user. A verified identity can read a private board. The link expires after at most 10 minutes and works once. The secret never leaves your server. ## Token format ``` base64url(JSON payload) + "." + hex(HMAC-SHA256(Secret key, base64url payload)) ``` The Secret key is under **Settings > Developers > Secret key** (it is shown once, when you generate it). Never put it in browser code. ## Code The dashboard card **Sign-on link** and the [MCP server](/ai/mcp) generate ready code for Node.js, Python, PHP, Ruby, Go and Java. Node.js example: ```ts import { createHmac } from "node:crypto"; // Server only. export function boardSignOnUrl(base: string, secret: string, user: { id: string; email: string; name: string }, path = "/roadmap") { const payload = Buffer.from( JSON.stringify({ userId: user.id, email: user.email, name: user.name, exp: Math.floor(Date.now() / 1000) + 300, nonce: crypto.randomUUID() }), ).toString("base64url"); const signature = createHmac("sha256", secret).update(payload).digest("hex"); return `${base}${path}?jocx_token=${payload}.${signature}`; } ``` Use the generated code from the dashboard as the source of truth for field names. --- # Brand Kit styles A **style** is a named look you make once in the Brand Kit, such as "Flash Sale" or "Holiday", and then pick by name. It sets colors, a gradient or image background and corner radius. Anything a style leaves empty keeps the workspace's base look. Open **Brand Kit → Styles** to create one. Start from a preset or from blank. ## Where a style applies | Where | How to pick it | | --- | --- | | Announcements | **Design** tab → **Style**. | | Surveys | **Delivery** tab → **Style**. | | Tour steps | Step settings → **Style** (modal, end and banner steps; a tooltip works too, but a picture background can make small text hard to read). | | Chat widget | **Brand Kit → Chat widget → Chat look → Header style**. The style colors the panel header. | You never type colors in a builder: pick a style by name and change it in the Brand Kit. Editing a style changes every item that uses it. If you delete a style, items that used it show the base look until you pick another. ## Chat look Under **Brand Kit → Chat widget → Chat look** you can also choose the launcher icon (chat, question, smile, or your own image URL) and the colors of visitor and agent message bubbles. Empty keeps the defaults. ## Image-only announcements Choose layout **Image only** in an announcement's **Design** tab when a designed picture is the whole announcement. - Add the image on the **Content** tab and write alt text (required, for screen readers). - Design for the size the panel shows for your format: modal 800 × 600, slideout 680 × 400, banner 1600 × 120, popover 640 × 360 pixels. The panel also gives a prompt you can paste into an AI image tool. - Optional **phone image** is used under 520 px wide; without it the main image scales down. - Optional **click link** opens in a new tab and counts as a click on the announcement. ## Banner position A banner can float over the page (the default), push the page down, or sit in a slot you choose. It can sit at the top or the bottom. | Mode | What happens | | --- | --- | | Float over the page | Covers the page edge, like before. | | Push the page down | The banner is part of the page, so content moves instead of being covered. A fixed header at the top of your page is moved down with it. | | Inside my own slot | Add `data-jcx-banner-slot` to an element on your page and the banner mounts inside it. Without that element it pushes the page down. | --- # Live chat `init()` mounts a chat bubble in the bottom-right corner. You need no extra code. ## How it works 1. A visitor clicks the bubble. The SDK creates or resumes their open conversation. 2. Messages arrive live over a WebSocket. If the socket does not connect within about three seconds, the widget polls every four seconds while the panel is open. 3. Sending a message always uses a normal HTTP request. 4. Your team replies from the **Inbox** in the dashboard. Outside business hours, the visitor sees your configured offline message. It is shown only in the widget and is not saved as a message. ## Help center Before a visitor's first conversation, the chat panel can show your [Help center](/guides/help-center): FAQs, articles and tours, with search. Anything a visitor types in the message box, the **Chat** tab and the **Still need help?** line all open a conversation with a person. Nothing is generated and no AI provider is called. Your team sees what a visitor opened or searched next to the chat. The widget uses `GET /public/help`. Without published content the panel opens a conversation right away. Chat is the master switch for the launcher: | Chat | Help center | What visitors get | | --- | --- | --- | | On | On | One launcher. Tabs from your order (Chat, Help, Articles, Learn); only tabs with content show. | | On | Off | One launcher that opens a conversation right away. | | Off | On or Off | No launcher. Turning Chat off hides the bubble even when the Help center has content. | Both are set in **Chat → Settings**: "Chat is on" under Chat widget, and the Help center switch, tabs and order under Widget home. Write the FAQs and articles under **Chat → Help center**. ## Settings Open **Chat → Settings → Chat widget**. | Setting | What it does | | --- | --- | | Chat is on | Off hides the chat bubble and stops new messages (the API answers `403 chat_disabled`). You can still read the inbox and reply by email to existing conversations. | | Show "Powered by justonecx" | A small line at the bottom of the chat window. On by default; anyone can turn it off. | | Header title and subtitle | Defaults "Support" and "We typically reply within a few minutes". Outside business hours the subtitle reads "Back Monday at 09:00" (the next opening). | | Show who is online | Up to three online teammates' photos in the header. When nobody is online the general header shows. Photos and names come from each member's profile. | | Greeting | A first bubble from you when a visitor opens chat with no conversation yet, after a delay of 0 to 30 seconds. Shown once per visitor. It is saved as the first message only when the visitor sends something, so your team sees what they saw. | | Email replies a visitor has not read | After the number of minutes you set, an agent reply the visitor has not seen is emailed to them once (when they gave an email). The message then shows "Sent by email". | The colors, launcher icon and header style are in the [Brand Kit](/guides/brand-styles). ## Messages Agents and visitors can use **bold**, *italic*, `code`, links, bulleted and numbered lists and code blocks (a small markdown subset; nothing else is rendered, and only http and https links work). Both sides have an emoji picker. Both sides can attach files: images (PNG, JPG, GIF, WebP), PDF, TXT and CSV, up to 10 MB each and 5 per message. The server checks the real file type, not the name. Images show in the conversation; other files download. ## Who is online The inbox shows a dot beside each open conversation: **online** (the visitor is on the page), **idle** (no sign of life for 2 minutes) or **left** ("Last seen 12 min ago"). When the visitor left and gave an email, the reply box offers **Also send by email**. ## After a conversation is closed The visitor can rate it, then **Start a new conversation**. The rating appears live in the thread header and the list row. Next to a customer, the **Insights** block lists their previous conversations with date, status and rating. ## Internal notes and mentions In an internal note, the **@** button mentions a teammate. They get a badge in the Chat header with an **Acknowledge** action, and an email when they are not in the inbox at that moment. Notes are never shown to the visitor. ## Unread badge The bubble shows a count of agent messages that arrived while the panel was closed. It resets when the panel opens. ## Hide chat for some users Pass `disableChat: true` to `init()`. Use it for your own staff. See [init](/api/init). ## Several widgets in one corner Chat, tour launchers, surveys and checklists can share a corner. The SDK stacks them so they do not overlap. You can choose which one sits closest to the corner in **Workspace Settings → Widget priority**. ## Privacy A conversation belongs to the browser that started it. Calling `identify()` does not give another browser access to it. Call [`reset()`](/api/reset) on sign-out so the next person on a shared browser starts fresh. --- # Custom domain Serve the hosted pages from your own hostname, such as `roadmap.yourcompany.com`. ## Set up 1. Open **Roadmap & Changelog > Publish > On your own subdomain** and add the hostname. Up to 3 per workspace. Choose which module the hostname shows, or the whole set. 2. Create the two DNS records the dashboard shows: - A **TXT** record at `_justonecx.` with the verification value. It proves you own the domain. - A **CNAME** from the hostname to `domains.justonecx.com`. An apex domain cannot hold a CNAME, so the dashboard shows an **A** record for it. 3. Wait. We re-check every 5 minutes. The status moves through these states: | Status | Meaning | | --- | --- | | Pending verification | TXT record not found yet. | | Verified | TXT found. We are getting the certificate. | | Active | HTTPS works. The page is live. | | Failed | A check failed. The dashboard shows why. | ## Notes - A CNAME alone is not enough. Anyone can point a CNAME at us, so the TXT record is what proves ownership. - The certificate is issued automatically and renewed for you. - Active domains are re-checked daily. If a record disappears the domain stops serving until it is fixed. - If your plan drops below Scale the domain stops serving. The hosted address keeps working. - Sign-on links work on your domain too. See [Sign-on link](/guides/board-sign-on). --- # Embed the roadmap, board and changelog Put a board on your own page. It inherits your font and uses your workspace theme. Call [`init()`](/api/init) once at your app root first. The board reuses that session, so a user you `identify()` is recognised in the board too. The drawing code loads the first time a board appears, so pages without one pay nothing. The dashboard (**Roadmap & Changelog > Publish**) and the [MCP server](/ai/mcp) generate the exact code for your framework, with your key filled in. ## Plain HTML ```html ``` ## React, Next.js and others ```tsx import { useEffect, useRef } from "react"; import { mountRoadmap } from "@justonecx/sdk"; export function RoadmapBoard() { const ref = useRef(null); useEffect(() => { if (!ref.current) return; const handle = mountRoadmap(ref.current); return () => handle.destroy(); // StrictMode mounts twice; destroy() makes that safe }, []); return
; } ``` In Next.js add `"use client"` at the top. In Vue, Svelte, Angular, Solid, Astro, Lit and the rest the pattern is the same: call `mountRoadmap`, `mountIdeas` or `mountChangelog` after mount and `destroy()` on unmount. ## Routing | `routing` | Behaviour | | --- | --- | | `"query"` (default) | The open idea lives in `?idea=slug`. Works on any page with no server setup. | | `"path"` | Uses real paths such as `/roadmap/add-zapier-integration`. Set `basePath` and add a catch-all route so a shared link reaches the same page. | | `"none"` | Keeps the state in memory only. | ```ts mountRoadmap(element, { routing: "path", basePath: "/roadmap" }); ``` On a static host, rewrite `/roadmap/*` to your roadmap page. The dashboard generates the rule for nginx, Netlify, Vercel and Cloudflare. ## Handle `mount*` returns `{ destroy(), navigate(slug | null), on(event, callback) }`. Events: `vote`, `submit`, `comment`, `react`, `navigate`. ## Content Security Policy The board loads a second script, `justonecx-boards.global.js`, from the same place as the SDK. If you use a strict CSP, allow that origin in `script-src`, or set `boardsUrl` in [`init`](/api/init). ## CMS and site builders WordPress, Webflow and Shopify: paste the HTML snippet from the dashboard into a custom code block. --- # Help center The Help center gives visitors answers before they wait for a person. It lives in the chat widget: when a visitor first opens the chat panel, they see tabs instead of an empty conversation. You manage it in the dashboard under **Chat → Help center**. You need no extra code. | Tab | What it shows | |---|---| | **Chat** | Opens a conversation with your team. | | **Help** | Your FAQs: a question list where each answer opens under it. | | **Articles** | Longer how-to pages, with a reader view. | | **Learn** | Product tours a visitor can start themselves. | A tab only shows when it has something to read. Help needs at least one published FAQ, Articles at least one published article, and Learn at least one tour that is offered and matches the visitor. If no tab besides Chat has content, the panel opens a conversation right away. Nothing is generated and no AI provider is called. ## FAQs Under **Chat → Help center → FAQs**, add a question and its answer, group them with an optional **Group**, put them in order and switch them on. Only published FAQs show in the widget. Answers support lists (`1. ` or `- `), **bold** and `http(s)` links. ## Articles Under **Chat → Help center → Articles**, write the article in the editor. Use **Preview** to check how it renders. A blank line starts a new paragraph. Each article gets a web address name (its slug) from its title when you create it, and that name never changes, so a link you shared keeps working. Only published articles show. ## Learn tab Open a tour in the builder, go to the **Settings** tab, **Launcher** section, and switch on **Offer this tour in the Learn tab**. You can set: - **Title** and **Short description** (the tour name is the default title). - **Start page**: a path like `/dashboard` or a full `http(s)` URL. The Learn tab lists a tour only to visitors the tour's [targeting](/guides/targeting) matches, and puts a tick next to tours they have finished. When a visitor clicks a tour: - On its start page (or when you set none), the chat panel closes and the tour starts. - On another page, the SDK goes to the start page and starts the tour there. ## Order and switches Under **Chat → Settings → Widget home**, turn the Help center off (the panel then opens a conversation right away), switch single tabs on or off, and move them up and down. The order you set is the order visitors see. Chat and the Help center are independent. With chat switched off and the Help center on, the widget still opens, but shows only the help tabs: no Chat tab, no "Chat with us" line and no message box. ## Search The search box looks through the question and answer of every published FAQ and the title and text of every published article. It is plain text matching, with title matches first. It shows on the Help and Articles tabs. ## What your team sees Every FAQ a visitor opens, every article they read, every search and every tour they start from the Learn tab is logged. When that visitor starts a conversation, your team sees the list next to the chat, so the first message does not need to repeat it. A visitor who identified on a second browser shows the first browser's activity too. Visitors can mark a FAQ or article **Was this helpful?** with 👍 or 👎. Your team sees the vote in the same list. ## A person is always one click away The **Chat** tab, the **Still need help? Chat with us** line under every view and typing in the message box all open a conversation. What the visitor typed is the first message. A visitor who has talked to a person once goes straight to their conversation on later visits. ## Permissions Reading needs **View chat** (or **View assigned chats**). Changing FAQs, articles and settings needs **Configure chat**. --- # Hosted pages Every workspace gets a hosted roadmap, ideas board and changelog. Nothing to install. ``` https://boards.justonecx.com//roadmap https://boards.justonecx.com//ideas https://boards.justonecx.com//changelog ``` Find the exact links, and the switch that turns the hosted page on or off, in **Roadmap & Changelog > Publish > On our hosted page**. ## Good to know - The server renders the pages, so search engines and link previews see the real content. - Each idea and changelog entry has its own link, for example `//ideas/add-zapier-integration`. - Public boards are indexable. Identified and private boards send `noindex` and are never cached. - When you also serve the boards on a [custom domain](/guides/custom-domain), the pages tell search engines the custom domain is the original. - The pages use your workspace Brand Kit (colours and shape). - Optional: turn on **Offer an RSS feed of the changelog** (Roadmap & Changelog, Publish, On our hosted page) to serve a feed at `//changelog/feed.xml`. It is off by default. ## Who is signed in A visitor on a hosted page is anonymous unless you link them with a [sign-on link](/guides/board-sign-on). --- # Identity verification ## What it is, in plain words Your Public key (`pk_…`) is in your site's code, so anyone who opens your site can read it. Without more, anyone can then call `identify("someone-else")` and write to that person's profile, chat history and feedback. Identity verification closes that gap. After a user signs in, **your server** signs their user id with your Secret key. JustOneCX checks the signature and ignores `identify()` calls that do not have a valid one. ## Why you set it up on your server Only a secret that never reaches the browser can prove who is signed in. That is why the Secret key must live in **your server's environment**, as `JUSTONECX_SECRET_KEY`. JustOneCX cannot place it there for you, and it must never go in browser code. You do this once per site. It is not per user and not per page. ## Set it up, in this order The order matters. If you turn on **Require** before your site sends the signature, all your existing users count as unverified. 1. **Copy the Secret key.** Open **Settings → Developers → Secret key**. The full key is shown only once. If you missed it, regenerate it and copy the new one. 2. **Store it on your server only**, as `JUSTONECX_SECRET_KEY` (hosting dashboard, server `.env`, or CI secret). Add the empty name `JUSTONECX_SECRET_KEY=` to `.env.example`. 3. **Sign the user id on your server** after login (code below), and send the result to the browser with the signed-in user, for example in your session payload. 4. **Pass it to the SDK** in the browser, and deploy. 5. **Switch on Require.** In **Settings → Developers → Identity verification**, turn on **Require identity verification**. 6. **Check it works.** Sign in on your site, then look at the setup checklist (or ask your AI assistant to run `workspace_status`). It should say verified sign-ins are arriving. ## Sign on your server The Secret key is the same key you use to [send events from your server](/guides/keys). One key, one place to rotate. ::: code-group ```js [Node.js] import { createHmac } from "node:crypto"; const userHash = createHmac("sha256", process.env.JUSTONECX_SECRET_KEY) .update(String(user.id)) .digest("hex"); ``` ```python [Python] import hmac, hashlib, os user_hash = hmac.new( os.environ["JUSTONECX_SECRET_KEY"].encode(), str(user.id).encode(), hashlib.sha256 ).hexdigest() ``` ```ruby [Ruby] require "openssl" user_hash = OpenSSL::HMAC.hexdigest("SHA256", ENV.fetch("JUSTONECX_SECRET_KEY"), user.id.to_s) ``` ```php [PHP] $userHash = hash_hmac('sha256', (string) $user->id, getenv('JUSTONECX_SECRET_KEY')); ``` ::: ## Pass it to the SDK ```ts identify(user.id, { plan: user.plan }, { userHash }); group(account.id, { plan: account.plan }, { accountHash }); ``` `accountHash` is the HMAC of the account id, computed the same way. Sign exactly the same string you pass to `identify()`. ## Set it up with an AI coding assistant If your assistant is connected to the [JustOneCX MCP server](/ai/), ask it: "Set up JustOneCX identity verification". It reads the exact steps from `get_install_info`, adds the server code and `.env.example` entry, and passes `userHash` to `identify()`. It never sees your Secret key, on purpose: you paste the key into your server environment yourself. It switches **Require** on only after your site sends `userHash` and you say yes, because that setting changes the live site. ## If it does not work | Symptom | Likely cause | |---|---| | Users show as unverified after you turned Require on | Your site does not send `userHash` yet, or it signs a different string than the user id it passes to `identify()` (a number vs a string, or an email vs an id). | | Every hash is rejected | You signed with the Public key (`pk_…`) instead of the Secret key. | | It worked, then stopped | The Secret key was rotated and the server still has the old one. | | The hash is computed in the browser | Do not. The Secret key would be public. Compute it on the server. | To test one hash, ask your assistant to verify a `userId` and the hash your server made. It tells you whether it matches, and never shows the expected value. ## Roadmap and changelog Identity verification also protects the boards. A private board only opens for a visitor whose identity was verified, and an unverified `userId` never counts. On a hosted page or custom domain use a [sign-on link](/guides/board-sign-on). ## What happens without a valid hash When verification is on, a call without a valid hash returns normally but is ignored. It cannot merge into another person's profile or change their traits. ## Rotating the Secret key Rotating the Secret key invalidates every hash you issued and every server event call that still uses the old key. Your server must use the new key for **both** event ingest and `userHash`. Copy the new key from the dashboard right after you rotate, deploy it, then check that identify calls verify. ## If you used a separate identity secret before Older workspaces had a second key, the identity secret. It still works until you rotate your Secret key, and the dashboard shows a banner while it does. After you rotate, only the Secret key is accepted. --- # Keys You use three credentials. Each has one job. | Credential | Where it goes | What it does | |---|---|---| | **Public key** | The browser: `init({ publicKey })` | Shows tours, announcements, surveys, chat and the Help center to your visitors. Safe to share. | | **Secret key** | Your own server only | Sends events from your backend ([server events](/api/server-events)) and signs each user id for [identity verification](/guides/identity-verification) (`userHash`). | | **Agent token** | Your AI tool | Lets Claude, ChatGPT, Cursor or another AI tool act for one person. Connecting through OAuth creates it for you. | You find the Public key and the Secret key under **Settings → Developers**. ## Public key Anyone can read it in your page source, so it only grants access to content you already published. It starts with `pk_`. If it leaks, add your site under [allowed origins](/guides/allowed-origins) so other websites cannot reuse it, or regenerate it (every embed then needs the new key). ## Secret key It starts with `sk_`. It belongs in your server environment, for example as `JUSTONECX_SECRET_KEY`. Never put it in client code, a mobile app or a public repository. - The full key is shown **once**, when you generate it. We store it only encrypted and as a hash. After that the dashboard shows its first characters. - **One key does two jobs.** It authenticates server-to-server event calls and it is the secret for `userHash`. If you rotate it, your server must use the new key for **both**. - Rotating it invalidates the old key immediately, in both places. - A leaked Secret key lets someone send fake events and forge user identities. Treat it like a password and rotate it if you think it leaked. Workspaces created before 2026-10-17 also had a separate **identity secret**. It still works until you rotate your Secret key, and the dashboard shows a banner while it does. Rotating the Secret key retires it. ## Agent token An agent token is per person, not per workspace. It has the same permissions as the person who made it, narrowed to what you allow. The easiest way is to connect your AI tool through the MCP server and approve it in the browser (OAuth). See [AI assistants](/ai/). --- # Multi-page tours One tour can walk a visitor across several pages. Each step can belong to its own page, the SDK saves where the visitor is, and the tour carries on after a page change, in another tab or on another device. ## Put a step on a page In the step's **Pages, targets and completion** section: - **Same page as the step before** (the default). Switch it off to give the step its own page rule: is exactly, starts with, contains, matches pattern (`*` is one path segment, `**` any depth) or regex. - **Button takes the visitor to**: a path or URL. When the next step is on another page, this step's button goes there. **Button text when it leads to another page** replaces the button label. Until the visitor is on a matching page, nothing shows. When they arrive, the tour continues from that step. Call `init()` on every page, or use `trackRouteChanges: true` in a single-page app, so the SDK can check again after each navigation. A multi-page tour that is interrupted by navigation is **paused**, not dismissed. Its place is saved. A tour with no per-step pages still counts a navigation away as dismissing it, as before. ## Progress is kept on the server The SDK saves the current step (and what happened on each step) for each person. That is what lets a tour resume after a reload, and it follows the person to another browser once they `identify()`. When a person returns after a while, the tour continues from where they stopped. To start one person over, use **Start one person over** in the tour's **Results** tab, or **Restart** next to a tour in a customer's **Insights** in the chat inbox. ## Find the element by text or role A tooltip can list more ways to find its element, tried in order after the selector: visible **text** (button, link, label or any element), an ARIA **role** with an accessible name, a `data-jcx` anchor, or a CSS selector. Text and role targets keep working when generated class names change. A tooltip needs at least one of: a selector, or one of these. ## Required steps and skip reasons Mark a step **Required**. When a visitor closes it, they pick a reason first (set the list in the tour's **Settings** tab, **Targeting** section, **Skip reasons**; defaults: Not relevant, Already done, Later). The reasons appear per step in **Results**. ## Results **Results** shows started, finished, finished with skips, abandoned (no activity for 7 days), the biggest drop-off (with its page), a per-step table (reached, skipped by reason, target missing, median time) and a split by any user trait you send with `identify()`, for example `plan`. --- # Payload CMS and other admin panels Admin panels such as Payload CMS are a good fit for tours: one login, many screens. This is what to set up. ## Where to put the code - **`init()`**: once, in the admin app's root client component (in Payload, a custom component registered as a provider or a `beforeDashboard` component). Use `trackRouteChanges: true`, because the admin is a single-page app and its screens change without a reload. - **`identify()`**: right after the signed-in user is known, with their id and traits (`role`, `email`). ```js import { init, identify } from "@justonecx/sdk"; void init({ publicKey: "pk_your_key", apiUrl: "https://api.justonecx.com", trackRouteChanges: true, excludePaths: ["/api/**"] }); identify(user.id, { email: user.email, role: user.role }); ``` ## Give elements stable anchors Payload's generated class names change between versions. Add a `data-jcx` attribute to the components you want to point at, in your own custom components: ```tsx ``` Then point the tour at `[data-jcx="posts-create"]`. Elements you cannot edit (Payload's own buttons) can be found by their visible text or ARIA role instead: add a text or role target in the step. ## Keep the storefront out If the same app serves a public site and the admin, only the admin should show tours. Use `excludePaths` for the public paths, or `disableTours: true` where the SDK should not run tours at all. See [init](/api/init) and [Targeting](/guides/targeting#turn-the-sdk-off-on-some-pages). ## Live preview frames Payload's live preview renders your site in an iframe. Add `excludePaths` for the preview route, or `disableTours`, `disableAnnouncements` and `disableSurveys`, so the frame does not show widgets. A second copy of the SDK on a page logs a warning and stays inactive. --- # Roadmap, ideas and changelog Show customers what you are building, let them request and vote on ideas, and tell them what shipped. One setup, three ways to show it: - **On your website**, embedded in any page, plus an in-app **What's new** panel. See [Embed](/guides/embed-roadmap-changelog) and [What's new widget](/guides/whats-new-widget). - **On your own subdomain**, such as `roadmap.yourcompany.com`. See [Custom domain](/guides/custom-domain). - **On our hosted page** at `boards.justonecx.com//roadmap`. No code. See [Hosted pages](/guides/hosted-boards). All of them show the same content. You manage it in the dashboard under **Roadmap & Changelog**, and you turn each way on or off in its **Publish** tab. ## The three parts | Part | What visitors see | | --- | --- | | Roadmap | Ideas grouped by status: planned, in progress, shipped. | | Ideas | Ideas with votes and comments. Visitors can submit new ones. | | Changelog | The entries you published, newest first. Visitors can mark an entry helpful. | ## From idea to changelog 1. A customer submits an idea, or you add one yourself. 2. You move it through the roadmap: **Planned**, **In progress**, **Shipped**. 3. When you mark it **Shipped**, its voters get an email and a changelog **draft** is created for it. 4. You edit the draft in the **Changelog** tab and publish it. Optionally, publishing also shows a banner in your product that links to the entry. Nothing reaches the changelog until you publish it. Announcements you publish in your product do not add changelog entries. ## Who can see and act Pick an access mode in **Roadmap & Changelog > Publish**: | Mode | Effect | | --- | --- | | Public | Anyone can read. Writing (vote, comment, submit) needs no sign-in. | | Identified | Anyone can read. Writing needs a visitor you identified. | | Private | Only customers you signed in with a verified identity can read. | Private boards need [identity verification](/guides/identity-verification) or a [sign-on link](/guides/board-sign-on). The server enforces the mode; hiding the page in your own app is not enough. ## Moderation New ideas and comments can wait for approval. Only approved content is public. Turn it on in **Publish** and review in **Roadmap & Changelog > Moderation**. ## Who voted Open an idea to see who voted and which account they belong to. Sort the idea list by identified people or accounts to build for your customers first. Visitors never see this. Public pages show no emails and no full names. ## Email When you change an idea's status or reply as the team, voters and commenters with an address get an email from your workspace name. Every email has a one-click unsubscribe link. ## Plans Hosted pages, embeds and custom domains are in every plan. --- # Steps that finish on an event A step can finish when something happens instead of when the visitor clicks a button. In the step's **Pages, targets and completion** section, pick **What completes this step**: | Choice | The step finishes when | | --- | --- | | The step's button | The visitor clicks it (the default). | | A click on the element | The visitor clicks the highlighted element (tooltip steps). | | An event | A matching event arrives. | ## Events Give the event name and, optionally, properties that must match (`plan=pro, source=import`). Choose where it may come from: - **From the browser or your server.** A `track()` call on the page finishes the step at once. An event your server sends does too. - **Only from your server (confirmed).** Use it for things the browser cannot prove, such as a successful payment or an import that finished in a job. A browser `track()` call never finishes this step. Send server events with your Secret key (see [Server events](/api/server-events)) and the person's `userId`, so the event reaches the same profile as the visitor. ## How the widget finds out For a server-confirmed step, the widget asks the server where the person is every 10 seconds while the step is open, and moves on when the server has moved them. There is no push channel for tours yet, so allow up to about 10 seconds after your server event. An event only moves a person who is on that step. A tour that is not started, or is on a different step, ignores it. --- # Surveys and NPS Ask visitors how it is going. Surveys support NPS, CSAT, CES and free-text feedback, and can use the block builder for custom layouts. ## Create a survey 1. Open **Surveys** in the dashboard. 2. Pick a type and write the question. 3. Choose where it appears: slide-out, modal or banner. 4. Choose when it appears: on page load, after scrolling, or when your code sends a specific event. 5. Set repeat frequency. 6. On the **Settings** tab, choose who sees it, then publish. Answers are on the **Results** tab. ## Trigger a survey from your code Set the survey trigger to **event** and enter an event name. Then call: ```ts import { track } from "@justonecx/sdk"; track("completed_onboarding"); ``` The next `track()` call with that exact name shows the survey once. ## Show once per order, ticket or purchase By default a person sees a survey once, or again after a throttle period. To ask after every order instead, set **Show once per (event property)** in the **Delivery** panel of the survey builder (event triggers only), for example `order_id`, and send that property with the event: ```ts track("purchase_completed", { order_id: "A-1042" }); ``` The survey now shows at most once for each different `order_id`, for each person. A second `track()` with the same order does nothing, whether the person answered or dismissed. Because every order is a new chance to ask, the Frequency setting and the throttle between surveys do not apply to a survey with this option. You can also send the event from your server with the Secret key, using `userId` and the same property. The survey then shows on that person's next page load, once for that order. ## Where a survey shows Surveys use the same targeting editor as tours and announcements, in the survey's **Settings** tab: who sees it (segments and conditions), which pages (show on, hide on, with exact, prefix, wildcard and regex rules) and when. A survey with page rules only opens when the visitor is on a matching page. An event trigger that fires on another page waits and shows when the person reaches a matching page. ## Follow-ups and branching You can show different follow-up questions by score band, for example a different prompt for detractors and promoters. ## Test mode Use the **Test survey** mode in the builder to see the survey without saving a response. ## Responses Responses appear in the dashboard under the survey. A survey type cannot change after its first response. ### Download responses Open the survey's **Results** tab and choose **Download responses (CSV)**. The file has one row per response with the answers, score, person and time. Cells that start with `=`, `+`, `-` or `@` are prefixed so spreadsheets do not run them as formulas. The same data is available from the API (admin sign-in required): `GET /workspaces/:id/surveys/:surveyId/responses?format=json` returns pages (`page`, `pageSize`); `format=csv` streams the whole file. --- # Targeting and segments Targeting decides who sees a tour, announcement or survey. The backend applies it before content reaches the browser, so hidden content is never sent. ## One model for tours, announcements and surveys Every tour, announcement and survey has the same three parts. Each part is optional, and they all have to match. | Part | What it does | Where it is checked | | --- | --- | --- | | Audience | **Only these segments** (a visitor in any of them), **Never these segments** (a visitor in any of them never sees it), optional inline conditions, and new vs returning visitors. | Server, before content is sent. | | Pages | **Only on these pages** and **Never on these pages**, each a list of rules. | The visitor's browser, on every page change. | | Schedule | A start and end time. Tours only: announcements set theirs in the **Schedule** section of their **Settings** tab, surveys have none. | Server. | Leaving a part empty means "no restriction": no segments and no rules show it to everyone, everywhere. ### Page rules Each rule has an operator and a value. A page is included when it matches any **include** rule (or when there are none) and matches no **exclude** rule. | Operator | Matches | | --- | --- | | is exactly | The path equals the value. | | starts with | The path starts with the value. | | contains | The path contains the value. | | matches pattern | `*` stands for one path segment, `**` for any depth: `/projects/*/settings`, `/docs/**`. | | matches regex | A regular expression on the path. An invalid expression never matches. | The query string and `#hash` are ignored unless you tick **query** or **#hash** on the rule. The builder has a **Test a URL** box that shows whether a URL would match. ### Turn the SDK off on some pages Use the [`excludePaths`](/api/init) option when nothing from JustOneCX should show on a page (an admin area, a preview frame): it hides chat, tours, announcements, surveys and What's new there. To turn off one kind everywhere on a page, use `disableTours`, `disableAnnouncements` or `disableSurveys`. A second copy of the SDK on the same page (a tag manager plus the npm package, for example) logs one warning and stays inactive, so chat and tours do not appear twice. ## Where the data comes from - [`identify`](/api/identify) sets user traits such as `plan` and `role`. - [`group`](/api/identify#group) sets account traits. - [`track`](/api/identify#track) records events. An anonymous visitor who never calls these still matches page rules, schedules and new-vs-returning rules. Segments and inline conditions need trait data. An account-level segment never matches a visitor who has not called `group()`. ## Create a segment 1. Open **Segments** in the dashboard. 2. Define conditions on the traits you send with `identify` and `group`. 3. Use the segment in a tour, announcement or survey. ## What a segment can use | Attribute | What it reads | | --- | --- | | Role, Plan | The `role` and `plan` traits you send with `identify`. | | Custom trait | Any trait you send with `identify` (enter its key). | | Account trait | A trait you send with `group` (enter its key). | | Identified | Whether the person has a user id (called `identify`) or is anonymous. | | Signup date, First seen, Last seen | When the person (or account) was first and last seen. | | Account MAU | Distinct active users of the account in the last 30 days. | | Performed event / Never performed event | Whether the person sent an event (from the browser or your [server](/api/server-events)), at least N times, optionally in the last N days. | | Completed tour / Dismissed tour | Whether the person finished or dismissed a tour (enter its key or id). | | Answered survey / Saw announcement | Whether the person answered a survey or saw an announcement (key or id). | | Opened chat, Chat rating | Whether the person started a chat, and whether they rated one at most N out of 5. | A segment that **measures accounts** can only use the account-level attributes (traits, signup/first/last seen, MAU). The rest describe one person. **What is not a segment attribute:** the page the visitor is on, their device, language and country. Those only exist in the browser, so they are checked there: use [page rules](#page-rules) for the current page and the SDK's context options for the rest. A segment is decided on the server before content is sent, so it cannot depend on them. ## Groups A segment is made of groups. Conditions inside a group combine with the group's **Match all** (every condition) or **Match any** (at least one). With two or more groups, the top-level choice decides how the groups combine: **all groups** or **any group**. This covers "(role is admin and plan is pro) or plan is free" without nesting further. Segments made before groups existed were converted when you upgraded: a chain of AND conditions became one group, and a chain with OR was split at each OR into groups joined by **any group**. The old chain was evaluated strictly left to right, which groups cannot copy exactly, so check a segment that mixed AND and OR. ## Operators Each condition compares one attribute with a value. Which operators you can pick depends on the attribute. | Operator | Meaning | | --- | --- | | equals / does not equal | Exact match (a date compares the day). | | contains / does not contain | Text contains the value, ignoring case. | | greater than / less than | Numbers, or dates for the signup date. | | between | From and to, both included. | | is set / is not set | The trait has a value / has none (an empty text counts as none). | | within last N days / not within last N days | The signup date is that recent / older. | **Negative operators match people who have no value.** "Plan does not equal pro" includes someone with no plan at all. Use **is not set** when you want to target people with a missing value on purpose, and **is set** to require one. "Account MAU less than 5" includes accounts and users with no activity, because they have an MAU of 0. ## Keep traits fresh Traits merge, so call `identify` and `group` on every page load. If you need targeted content to show right after a trait changes, `await identify(...)` and then call `init()` again. --- # Product tours A tour is a series of steps that point at elements on your page, or show a modal or banner. Visitors move through it with Next and Back. ## Create a tour 1. In the dashboard, open **Tours** and create one, from scratch or from a template. 2. On the **Builder** tab, add steps. For a tooltip step, pick the element it points at. 3. On the **Settings** tab, set targeting (who sees it, on which pages). The same tab holds the launcher, the A/B test, voice narration and version history. 4. Publish. The builder has three tabs: **Builder** (the steps), **Settings** (everything about the tour that is not one step) and **Results**. Save as template, Brand Kit, Unpublish and Delete are in the **⋯** menu next to Save. The SDK fetches published tours on `init()` and plays the ones that match the visitor. ## How steps are shown - A tooltip step highlights the element found by the step's selector. - The SDK scrolls the element to the middle of the screen before it places the tooltip. This also works inside scrollable panels and iframes, and it skips the animation when the visitor prefers reduced motion. - If the element is not on the page yet, the SDK waits for it (5 seconds by default; set up to 30 seconds per step). If it never appears, the step is skipped, the skip is recorded, and the tour finishes as **finished with skips** instead of a clean completion. The builder marks such steps "never saw it". - If no step in a tour can be shown, nothing is displayed and the tour is not marked as seen, because the visitor never saw it. - Finishing or dismissing a tour marks it as seen. It is not shown again automatically. See [Multi-page tours](/guides/multi-page-tours) for steps on different pages, text and role targets, event-completed steps and required steps. ## Replay and launcher Enable the **persistent launcher** in the **Launcher** section of the tour's **Settings** tab to show a "Replay tour" button. It stays available after the visitor finishes. You can set its screen corner. A visitor can dismiss it for good in their browser. You can also build your own button with [`startTour`](/api/reset#starttour). The tour's **frequency cap** limits only automatic playback. A manual replay always works. ## Checklists A checklist step shows tasks in a progress widget that stays in the corner of the page. Add one with **Add step → Checklist** on the Builder tab; a tour can be a checklist alone. A visitor completes a task by clicking it. You can instead bind a task to an event name. When your code calls `track("that_event")`, the task completes. Whichever happens first wins. ## Single-page apps Set `trackRouteChanges: true` so page-based targeting re-runs on each navigation. See [init](/api/init). ## Preview on your site The tour builder has a **Preview on your site** action. It uses the JustOneCX browser extension to play your draft on your own live page. --- # What's new widget An in-app panel that shows your changelog (or roadmap), with a badge for unread entries. It opens from your own button or from a floating button we add. Turn it on under **Roadmap & Changelog > Publish > On your website**, and pick how customers open it. **On your website** must be on too. Call [`init()`](/api/init) once first. ## Attribute, no code ```html ``` Works on any element, including ones your framework renders later. The SDK sets `data-justonecx-unread="3"` on the button when there are unread entries and adds a badge. Style it yourself with that attribute. ## Functions ```ts import { openChangelog, openRoadmap, getUnreadChangelogCount, onUnreadChangelogCountChange } from "@justonecx/sdk"; openChangelog(); const unread = getUnreadChangelogCount(); const stop = onUnreadChangelogCountChange((count) => setBadge(count)); ``` From the script tag use `window.JustOneCX.openChangelog()` and the other functions. ## Unread An entry is unread when it was published after the visitor last opened the panel. Identified visitors keep that state across devices. --- # Frameworks Every framework uses the same calls. Three rules apply everywhere: 1. Call `init()` once, in the browser only, after the app mounts. 2. In single-page apps, pass `trackRouteChanges: true`. The SDK then re-runs itself on each client-side navigation, so page targeting stays correct. 3. Call `identify()` after sign-in and `reset()` on sign-out. Use these options in all examples: ```ts const options = { publicKey: "pk_your_workspace_key", apiUrl: "https://api.justonecx.com", trackRouteChanges: true, }; ``` ::: info The examples import `@justonecx/sdk`. If you have not installed it, use the [script tag](/install/script-tag) instead. ::: ## React, Next.js, Remix, Preact ```tsx "use client"; // Next.js App Router only import { useEffect } from "react"; import { identify, init } from "@justonecx/sdk"; export function JustOneCX({ user }: { user?: { id: string; plan: string } }) { useEffect(() => void init(options), []); useEffect(() => { if (user) void identify(user.id, { plan: user.plan }); }, [user]); return null; } ``` Render `` once in your root layout. ## Vue and Nuxt ```ts // Vue: main.ts, after app.mount() import { init } from "@justonecx/sdk"; void init(options); ``` ```ts // Nuxt: plugins/justonecx.client.ts // The ".client" suffix keeps it out of server rendering. import { init } from "@justonecx/sdk"; export default defineNuxtPlugin(() => { void init(options); }); ``` ## Angular ```ts import { isPlatformBrowser } from "@angular/common"; import { Component, PLATFORM_ID, inject } from "@angular/core"; import { init } from "@justonecx/sdk"; @Component({ selector: "app-root", templateUrl: "./app.html" }) export class App { constructor() { if (isPlatformBrowser(inject(PLATFORM_ID))) void init(options); } } ``` ## Svelte and SvelteKit ```svelte ``` Put this in `+layout.svelte` (SvelteKit) or `App.svelte`. ## Solid and SolidStart ```tsx import { onMount } from "solid-js"; import { init } from "@justonecx/sdk"; onMount(() => void init(options)); ``` ## Astro, Lit, Alpine, jQuery, plain TypeScript Call `init(options)` from any client-side script. In Astro, put it in a ` ``` Place it before ``. The script reads its own `data-*` attributes and starts by itself. ## Attributes | Attribute | Required | Description | | --- | --- | --- | | `data-public-key` | Yes | Your workspace public key. | | `data-api-url` | Yes on real sites | Backend origin. Without it the SDK uses `http://localhost:8000`, which only works for local development. | | `data-track-route-changes` | No | Set to `"true"` on sites that change pages without a full reload. | ## Pin a version (optional) The URL above always serves the latest SDK and is cached for five minutes. We recommend it for almost every site. Pin a version only if you need to lock to one release. A pinned file never changes. Replace `` with the version you want: ```html ``` See [SDK versions](/versions). ## Calling the API The script adds `window.JustOneCX`: ```js window.JustOneCX.identify("user-42", { plan: "growth" }); window.JustOneCX.track("clicked_upgrade"); window.JustOneCX.reset(); ``` Board functions are there too: `mountRoadmap`, `mountIdeas`, `mountChangelog`, `openChangelog`, `openRoadmap`. The script also registers ``, `` and ``. When a board first appears the script loads `justonecx-boards.global.js` from the same place. See [Embed](/guides/embed-roadmap-changelog). Calls made before the script has loaded fail. Place your own code after the script tag, or run it after the `load` event. ## Content Security Policy If your site sends a CSP header, allow our script and API origins. See [Content Security Policy](/security#content-security-policy). ## Platform notes Step-by-step paths for each builder are on [Website builders](/install/website-builders). In short: - **WordPress**: add the snippet with a header/footer plugin, or in your theme's `footer.php` before `wp_footer()`. - **Webflow**: Project settings → Custom code → Footer code. - **Shopify**: edit `theme.liquid` and paste the snippet before ``. - **Google Tag Manager**: use a Custom HTML tag that fires on all pages. Add `data-track-route-changes="true"` if the site is a single-page app. --- # Website builders You don't need a developer to add JustOneCX to a site made with a website builder. Every builder below has a place to paste one snippet. You paste it once, publish, and everything you build in the dashboard shows up on the site. ## Before you start 1. In the dashboard, open **Developers** and copy your snippet. It looks like this and already holds your Public key: ```html ``` 2. Add your live site address under **Settings → Developers → Domains & CORS**, for example `https://www.example.com`. Without it, nothing shows. See [Allowed origins](/guides/allowed-origins). 3. After you paste the snippet, **publish your site**. Most builders don't run custom code in their editor preview. The paths below are the ones each builder documents today. Builders move menus around, so if a name differs, search that builder's help for "custom code". ## Webflow 1. Open **Site settings → Custom code**. 2. Paste the snippet into **Footer code**. 3. Save, then publish. Webflow documents custom code as a paid-plan feature, so check your plan if the field is missing. Code that only some pages need goes in that page's own settings instead. ## Framer 1. Open your site's settings and find **Custom Code**. 2. Paste the snippet into the **End of `` tag** field. 3. Publish. The change doesn't show in the editor preview. ## Shopify 1. Open **Online Store → Themes**, then **Actions → Edit code**. 2. Open `theme.liquid` under **Layout**. 3. Paste the snippet just above the closing `` tag. Don't remove the lines around it. 4. Save. If your theme has more than one layout file, paste it into the one your store pages use. ## Wix 1. Open **Settings → Custom Code** in your site's dashboard (in the **Advanced** section). 2. Choose **Add Custom Code** and paste the snippet. 3. Under **Place Code in**, choose **Body - end**, and apply it to all pages. 4. Apply, then publish. Wix asks for a published site with a connected domain. Wix doesn't support third-party code, so test on your live site. If nothing appears, check the [troubleshooting page](/troubleshooting). ## Squarespace 1. Open **Settings → Advanced → Code Injection**. 2. Paste the snippet into the **Footer** box. 3. Save. Code Injection needs a Business plan or higher. It doesn't run on checkout pages. ## WordPress Use a plugin that adds code to your site's footer, then paste the snippet into its footer box. If you edit your theme instead, add the snippet in `footer.php` just before `wp_footer()` and use a child theme, so a theme update doesn't remove it. ## Google Tag Manager Create a **Custom HTML** tag with the snippet and fire it on **All Pages**. ## Check that it works Open your published site in a new tab, then look at the status next to the page title in the dashboard. It changes from **Waiting for first visit** to **Connected**. The **Event log** shows the visit as it arrives. If it doesn't, see [Troubleshooting](/troubleshooting). ## What a builder can't do for you A website builder can't tell JustOneCX who is signed in. Everyone who visits sees the same tours, announcements and surveys, and the tour or survey can still target a page or a time. To show something only to paying users or to new sign-ups you need your developer to add [`identify`](/api/identify), or an AI assistant in your own project. See [Build with AI](/ai/). ## Built with an AI app builder If an AI tool such as Lovable made your app, ask it: "Add this script tag to my app's `index.html` before ``" and paste the snippet. Then publish. For the steps for connecting an AI assistant to JustOneCX itself, see [MCP server](/ai/mcp#lovable-and-other-chat-based-builders). --- # Public developer docs VitePress site served at `https://docs.justonecx.com`. Audience: developers adding the SDK to their product. This is **not** the internal `docs/` site (QA, architecture, database) and nothing internal belongs here. ```sh pnpm --filter public-docs dev # http://localhost:8082 pnpm --filter public-docs build # vitepress build + scripts/postbuild.mjs pnpm --filter public-docs test # checks code samples against the SDK's real exports ``` ## What the build emits `scripts/postbuild.mjs` adds AI-friendly files to `.vitepress/dist` next to the HTML: - `/llms.txt`: index of every page. - `/llms-full.txt`: every page in one file. `packages/mcp` reads this file for `search_docs`. - `/.md`: raw Markdown per page. - `/ai/justonecx-integration/SKILL.md`: agent skill, copied from `public/ai/`. ## Writing rules - Write for someone who has never seen the product. Short pages, one task each, copy-paste code. - Use the placeholder key `pk_your_workspace_key` and the real URLs `https://api.justonecx.com` and `https://app.justonecx.com/sdk/justonecx-sdk.global.js`. - `pnpm test` fails if a sample imports a name `@justonecx/sdk` does not export, or calls `window.JustOneCX.` for a function the script-tag bundle does not expose. Keep samples real. - New page: add the file, add it to the sidebar in `.vitepress/config.ts`. - Never hardcode an SDK version number in a page. Pinned URLs use ``, and the only version-bearing text is the "Current version" section of `versions.md`. Each SDK release adds its entry there (see `packages/sdk/DEVELOPMENT.md`, "Releasing"). - `@justonecx/sdk` is on npm (first published 2026-10-03; see `npm view @justonecx/sdk version` for the current one). The hosted MCP server at `/mcp` is the one way to connect an AI assistant; `@justonecx/mcp` is not published. The backend with `/mcp` is deployed, so `ai/mcp.md` has no "Coming soon" warning; the connect snippets there are mirrored in `MCP_CLIENTS` (`apps/frontend/src/components/landing/landingContent.ts`), so change both together. ## Deploy `scripts/vps/deploy.sh` builds this app and rsyncs it to `/var/www/justonecx-docs`. Use `deploy.sh --docs-only` for a docs-only release. Caddy config lives in `scripts/vps/Caddyfile`. --- # Security ## The public key is public The key that starts with `pk_` is like a publishable key. It is safe in page source. It identifies your workspace and only grants access to content you have published. It does not need to change when you add or edit content. Rotate it from **Settings → Developers** only if you want to retire an embed. ## Protect identities Anyone with your public key can call `identify` with any user id. Turn on [identity verification](/guides/identity-verification) and sign user ids on your server. Never put the Secret key in client code. ## Allowed origins The [allow-list](/guides/allowed-origins) restricts which browsers can use your key. It is not authentication. ## Content Security Policy If your site sends a Content-Security-Policy header, allow: | Directive | Value | | --- | --- | | `script-src` | `https://app.justonecx.com` (script tag install only) | | `connect-src` | `https://api.justonecx.com` and `wss://api.justonecx.com` (chat) | | `script-src` (boards) | The same origin also serves `justonecx-boards.global.js`, loaded the first time a board appears. Set `boardsUrl` in `init()` to move it. | | `style-src` | `'unsafe-inline'`, because the SDK injects one `