# Userport JavaScript SDK The complete integration reference: the widget, live chat, identifying users, contact properties, companies, identity verification, and the help center. Everything below is generated from the code that serves it — the field tables, the snippets and the limits are read from the endpoints themselves, not transcribed. Contents: - **Installation** — Add the loader script, confirm it booted, and configure the launcher. - **Live chat** — The conversation surface: starting a chat, attachments, conversation context, and the email fallback that only works if you sent an address. - **Identify users** — setUser(), every field it accepts, the fields it silently ignores, and when to call it. - **Contact properties** — custom_attributes: the supported keys, the value formats, and what happens to a key nobody defined. - **Companies** — There is no company payload: company_name is an identify field, everything else is a property. - **Identity verification** — HMAC-SHA256 of the identifier with the workspace secret; required to attach an identify call to a contact that already exists. - **Help center** — Article and collection URLs, putting it on your own domain, locales, and the article card inside the widget. - **JavaScript API** — Full reference for window.$helpmate — methods, window events, and when each is safe to call. --- # Installation Helpmate ships as one JavaScript loader. It creates the launcher, mounts the widget in an iframe, and exposes `window.$helpmate` for everything else in these docs. ## 1. Add the loader Paste this before the closing `
{children} ``` Nested objects are rejected, and so is anything past the 100th key in a single call. A value may be an array of scalars — a multi-select, stored as the comma-separated string you then target with `contains`; an empty one clears the property. ## Value formats Each property has a type, fixed the first time the workspace sees the key. The type decides which column the value is stored in, which is what makes `mrr > 100` and `signed_up_at` older than 30 days expressible at all. | Type | Accepted values | Example | | --- | --- | --- | | `string` | Any scalar — numbers and booleans are stringified. Trimmed, and truncated at 1024 characters. An array of scalars is joined with commas (["outreach","organize"] is stored as "outreach,organize") — that is how a multi-select is stored, and you target it with `contains`. An empty array clears the property. A nested array or an object is rejected. | `"SaaS"` | | `number` | A JSON number or a numeric string. Stored with 6 decimal places. A non-numeric value is rejected with a 422 — it is a bug on your side, and storing it would leave an audience rule that matches nobody. | `490` | | `boolean` | true / false. The strings "true", "false", "1", "0", "yes", "no", "on", "off" are accepted too. | `true` | | `datetime` | ISO 8601 ("2024-03-11" or "2024-03-11T09:15:00Z"), any date string Carbon parses, or a Unix timestamp — milliseconds are detected and divided down, so Date.now() works. | `"2024-03-11T09:15:00Z"` | | `list` | One of the options configured for the property in Settings. Behaves like a string otherwise — including a one-element array, which is unwrapped. It is a *single* select, so send one value: several are stored joined and then match no option. | `"enterprise"` | A value that cannot be coerced to the property's type is **rejected with a 422** rather than stored as a string. Sending `mrr: "lots"` is a bug on your side, and quietly keeping it would leave an audience rule that matches nobody — far harder to find than an error. Watch the `helpmate:error` window event during development; a rejected identify call surfaces there with the API's message. ## Supported keys These are predefined: their type is fixed, they have a proper name in the admin, and they are grouped sensibly in the audience builder. Use them where they fit rather than inventing a synonym — `subscription_tier` and `plan` are two properties as far as every filter is concerned. ### Person | Key | Name | Type | Group | Meaning | | --- | --- | --- | --- | --- | | `is_paying` | Paying customer | boolean | person | Whether the account currently pays you anything. | | `last_order_at` | Last order | datetime | person | Most recent purchase. | | `lifetime_value` | Lifetime value | number | person | Total revenue from this account to date. | | `locale` | Locale | string | person | Preferred language. Also used to pick the outbound message translation. | | `mrr` | MRR | number | person | Monthly recurring revenue for this account. | | `onboarding_goals` | Onboarding goals | string | person | What this account said it wanted, comma-separated. | | `role` | Role | string | person | The person's job role inside their organisation. | | `sdk_installed_at` | Installed at | datetime | person | When this account first got the product running. | | `seats` | Seats | number | person | Number of licensed users on the account. | | `setup_completed_at` | Setup completed | datetime | person | When this account finished setting the product up. | | `signed_up_at` | Signed up | datetime | person | When the account was created in your product. | | `trial_ends_at` | Trial ends | datetime | person | When the free trial expires. | ### Company Account-level attributes. See Companies for how these build a company record. | Key | Name | Type | Group | Meaning | | --- | --- | --- | --- | --- | | `articles_count` | Articles | number | company | How much content this account has published. | | `company_created_at` | Company created at | datetime | company | When the account was first created in your product. | | `company_id` | Company ID | string | company | Your own identifier for the account this contact belongs to. | | `company_industry` | Company industry | string | company | The account's industry or sector. | | `company_size` | Company size | string | company | Size of the account's organisation. Usually a bucket ("51-100", "500+") rather than an exact count — use `employees_count` for the number. | | `company_website` | Company website | string | company | The account's website. | | `conversations_count` | Conversations | number | company | How many conversations this account has handled. | | `linked_users` | Linked users | number | company | How many people from this account you have on file. | | `monthly_spend` | Monthly spend | number | company | What the account pays you each month. | | `plan` | Plan | list | company | Subscription tier. Options are managed in Settings. | | `workspace_slug` | Workspace | string | company | The account's workspace in your product — what a link back into it is built from. | ## Keys you invent Any key matching `[a-z0-9_]{1,64}` is accepted. The first time a workspace sends one, a property definition is created for it, typed as a string, and it appears in **Settings → Contact properties** where its name and type can be corrected. Two consequences worth knowing before you ship: - **A typo is permanent.** `plann` becomes a real property that clutters every field picker and matches nobody. Prefer the predefined keys, and check the spelling of your own. - **A property that already has values cannot be re-typed.** Values live in the column the original type selected. If you sent `seats: "12"` into a string property, fixing it means archiving that property and starting a new key. A workspace that has finished wiring things up can switch on **strict mode** in Settings → Contact properties, after which an unknown key is rejected with a 422 instead of quietly creating a definition. ## Reading them back Properties show on the contact in the agent inbox and in the directory, and they can be filtered and sorted on there. They are also the fields available in outbound message personalisation. --- # Companies Helpmate keeps a company record for every account you send details about. It powers the Companies tab of the directory, "everyone at this account" filters, and sorting a customer list by monthly spend. **There is no separate company object in the SDK.** You repeat the account's details on every user who belongs to it, and the company record is derived from that. This is deliberate: it means an installation that only ever wired up `setUser` gets companies for free, with no payload change. ## The shape `company_name` is a top-level identify field. Everything else — including the website — is a `custom_attributes` key. ```javascript window.$helpmate.setUser('user-42', { email: 'jane@acme.com', name: 'Jane Doe', company_name: 'Acme Inc', custom_attributes: { company_id: 'acme-inc', company_website: 'https://acme.com', company_industry: 'SaaS', company_size: 120, company_created_at: '2021-06-02', plan: 'enterprise', monthly_spend: 490, }, }); ``` ## What each key does | Key | Lands on | Notes | | --- | --- | --- | | `company_name` | `companies.name` | Sent as a top-level identify field, not a property. Keys a provisional company on its own. | | `company_id` | `companies.external_id` | Your own account id. Send it and the company row becomes authoritative — a provisional row keyed on the name is adopted rather than duplicated. | | `company_website` | `companies.website` | Absolute URL, e.g. "https://acme.com". | | `company_industry` | `companies.industry` | | | `company_size` | `companies.company_size` | Headcount, as a number. | | `company_created_at` | `companies.company_created_at` | When the account was created in your product. | | `monthly_spend` | `companies.monthly_spend` | What the account pays you each month, as a number. | | `plan` | `companies.plan` | Subscription tier. Grouped with the company keys even without the prefix. | | `linked_users` | `ignored` | Recomputed from the contacts pointing at the company. Sending it changes nothing — a host page cannot sort its own account to the top of the Directory. | ## Identified vs provisional companies How the record is keyed depends on whether you send `company_id`. - **With `company_id`** the record is *identified*. That id is your own account key, and it is authoritative: renaming the account in your product renames the company here, and does not create a second one. - **With only `company_name`** the record is *provisional*, keyed on the lower-cased name. You get a populated Companies list while the id is still being wired up. When an id finally arrives for an account previously known only by name, the existing record is **adopted** — same row, same history, same member count. It is not duplicated. The reverse works too: a user who sends only a name, at a company already known by id, joins the identified record rather than opening a provisional one beside it. Send `company_id` if your product has account ids. It is the difference between a company list you can trust and one that drifts every time somebody renames their workspace. ## One company per person A contact belongs to exactly one company. Sending a different `company_id` for an existing contact moves them, and both companies' member counts are recalculated. ## What is ignored `linked_users` is recomputed from the contacts pointing at the company and is ignored on the way in — a host page cannot sort its own account to the top of somebody's directory. --- # Identity verification Anything the browser says about who it is, the browser can lie about. Without verification an identify call may record whatever it likes on *its own* anonymous contact — name, email, user id — but it cannot attach to a contact that already exists, because otherwise anyone could type a customer's address into the console and be handed their conversation history. Verification lifts that restriction. Your server proves it knows a secret that the browser never sees. That is not a small difference in practice: an unverified install starts a **new** contact every time the same person arrives on a second device or with cleared cookies, and their conversation history does not follow them. ## How it works Compute an HMAC-SHA256 of the identifier using your workspace's identity verification secret, and render the digest into the page next to the identifier. Helpmate recomputes it and compares in constant time. ``` identifier_hash = HMAC_SHA256(identifier, workspace_secret) ``` The secret is per-workspace and is shown in **Settings → Installation**. Keep it in your server environment — never in client-side code, never in a public repository, never in a prompt handed to a third-party service. ## Compute the digest ### PHP / Laravel Compute the digest wherever you render the page, and pass it through to the identify call. The secret stays on the server — never ship it to the browser. ```php $identifier = (string) $user->id; $identifierHash = hash_hmac('sha256', $identifier, env('HELPMATE_IDENTITY_SECRET')); ``` ### Node.js ```javascript import { createHmac } from 'node:crypto'; const identifier = String(user.id); const identifierHash = createHmac('sha256', process.env.HELPMATE_IDENTITY_SECRET) .update(identifier) .digest('hex'); ``` ### Python ```python import hashlib, hmac, os identifier = str(user.id) identifier_hash = hmac.new( os.environ["HELPMATE_IDENTITY_SECRET"].encode(), identifier.encode(), hashlib.sha256, ).hexdigest() ``` ### Ruby ```ruby identifier = user.id.to_s identifier_hash = OpenSSL::HMAC.hexdigest('sha256', ENV['HELPMATE_IDENTITY_SECRET'], identifier) ``` ### Pass it to the widget The digest goes next to the identifier it signs. Send the same identifier you hashed — hashing the email and sending the user id verifies nothing. ```html ``` ## Rules - **Hash the identifier you send.** Hashing the email and sending the user id verifies nothing; the two must be the same string. - **The identifier must be stable.** Your primary key, not an email address that can change and not a session id that rotates. - **Compute it server-side, every time.** Caching the digest in `localStorage` or handing it to the client to build defeats the entire mechanism. - **Fail loudly when the secret is missing.** The usual way this ends up half-installed is code that computes the digest only when the environment variable is set, and quietly sends nothing when it is not. Everything keeps working, so nobody notices that no call is verified until a customer says their history vanished. Throw, log, or fail the build instead. ## Enforcement Verification is optional by default: an unverified identify call still records what the visitor says about themselves, so adding the feature does not break installations that have not wired it up yet. Once every page that identifies is sending a valid hash, turn on **enforcement** in Settings → Installation. From then on an unverified identify call is rejected outright with a 403 rather than being downgraded — which is what you want in production, and what makes the guarantee above absolute rather than best-effort. Enable it in that order. Switching enforcement on before the host page sends a hash silently stops identifying anybody. ## Rotating the secret **Settings → Installation → Identity verification → Rotate secret.** Rotating invalidates every digest computed with the old value, so deploy the new secret to your server first and rotate second. During the gap, unverified calls are downgraded (or rejected, if enforcement is on) — they are never mis-attributed. Rotate whenever the value has been somewhere it should not be: a committed `.env`, a pasted log, a chat thread, or a coding agent you no longer trust with it. --- # Help center Every workspace has a help center, created with it at signup. It is a public, crawlable site of collections and articles, and it is also where the widget's article card gets its content — the same articles serve a customer searching Google and a customer who opened the chat. There is nothing to install. It is hosted, and it is live before you write anything into it. ## URLs | Surface | URL | What is there | | --- | --- | --- | | Home | `https://{slug}.userport.io/` | Collections, search, and the contact entry point. | | Collection | `https://{slug}.userport.io/collection/{collection-slug}` | One group of articles. | | Article | `https://{slug}.userport.io/article/{article-slug}` | A published article. This is the link to send a customer. | | Changelog | `https://{slug}.userport.io/changelog` | Product updates, if the workspace publishes them. | | Contact | `https://{slug}.userport.io/contact` | A form for visitors who did not find an answer. | | Chat | `https://{slug}.userport.io/chat` | The chat as a page of its own — nothing else loads. The link to put behind a "Contact support" item in an app or an email footer. `?hm_message=` prefills the composer; `/?hm_chat=1`, the old form, redirects here. | | Localised | `https://{slug}.userport.io/{locale}/article/{article-slug}` | Any of the above under a locale prefix, for the languages the help center enables. | | Popular articles (JSON) | `https://api.userport.io/hc/{slug}/{locale}/articles.json` | What the widget's article card reads. Public, read-only, no token. | `{slug}` is the help center's own, set in **Settings → Help center**. Renaming it changes every URL above, so change it before you have links in the wild, not after. The chat URL is the one to know about if you have an app: the help center is hosted and live, so `/chat` gives you a working "Contact support" link with nothing installed anywhere. It is a page that is nothing but the chat — no header, no search, no article list loading first — and Live chat covers what else that link can carry. ## Your own domain Point a CNAME at the hosted host and set the domain in the help center's settings. Enter it as a bare hostname — `help.acme.com`, not `https://help.acme.com`; a scheme is stripped on save. Once it resolves, that domain becomes the canonical one: article links generated anywhere in Helpmate — an agent pasting an article into a reply, the widget's article card — are built on it, and the hosted subdomain keeps working underneath. Serving docs from your own domain is worth the DNS record. It keeps customers on your brand, and it is the difference between search traffic accruing to you and to a subdomain of ours. ## What goes in it | Type | Where it shows | |---|---| | **Collections** | Groups of articles; the home page is a list of them. | | **Articles** | The body of the documentation. Only published ones are public. | | **FAQ** | Question-and-answer entries on the home page, also emitted as `schema.org/FAQPage` structured data so search engines can surface them directly. | | **Changelog** | Dated product updates on their own page, for workspaces that publish them. | Drafts are invisible everywhere — the public site, the widget card, the JSON endpoint. There is no preview link to share; publish it or keep it in the editor. ## Languages A help center declares the languages it supports and a default. Enabled locales get a path prefix — `/fr/article/…` — and the widget follows the locale it resolved, so a visitor who booted the widget with `locale: 'fr'` is offered the French URLs. One honest limitation: **articles are not translated per locale yet.** The locale segment scopes the URL and the surrounding chrome, not the article body. A multilingual help center today means writing the articles in the language your customers actually read, not maintaining one article in five. ## Inside the widget When the help center has at least one published article, the widget's home screen grows an article card above the conversation entry point, showing the most-read articles and letting the visitor search before they ask. With no published articles the card is absent entirely — the widget is chat-only until you write the first one. That card is the cheapest support win available here: it deflects the questions you have already answered, and it costs one article. The content comes from the public JSON endpoint in the table above, which needs no token and can be read by anything. ## Linking to an article There is no SDK method that opens a specific article inside the widget. Two options, and the first is usually right: - **Link straight to the article URL** from your own UI — an empty state, a tooltip, a settings page. The customer gets a full page they can read, search and bookmark. - **Open the widget** with `window.$helpmate?.toggle('open')` and let them search the card, or link to `?hm_chat=home` from somewhere with no JavaScript of its own. This is for "I need help with something" rather than "here is the page about this". ## Search engines The help center is indexable and is meant to be. Articles are served as ordinary server-rendered pages, FAQ entries carry structured data, and a custom domain keeps that traffic on your brand. If you are deciding what to write first, write the articles your customers currently ask about in chat — the widget card and search results are fed by the same shelf. --- # JavaScript API Everything lives on `window.$helpmate`, which exists only after the `helpmate:ready` event. ```javascript window.addEventListener('helpmate:ready', function () { window.$helpmate.setUser('user-42', { email: 'jane@acme.com' }); }); ``` Outside a listener, guard the call: `window.$helpmate?.toggle('open')`. ## Methods | Method | Area | What it does | Throws | | --- | --- | --- | --- | | `$helpmate.setUser(identifier, user)` | Identity | Tell the widget who the visitor is. Safe to call on every authenticated page load — the SDK hashes the payload and skips the request when nothing has changed. | If the identifier is not a string or number, or the user object carries none of email / name / avatar_url. | | `$helpmate.setCustomAttributes(attributes)` | Identity | Write contact properties without re-identifying. This is how you update a property *after* the first setUser call: setUser is de-duplicated on a hash of the identifier, email, name and avatar_url only, so changing nothing but custom_attributes will not send anything. | If the map is empty. | | `$helpmate.deleteCustomAttribute(key)` | Identity | Remove one property value from the contact. The definition stays in Settings. | If the key is empty. | | `$helpmate.setLabel(label) / removeLabel(label)` | Identity | Add or remove a tag on the current conversation. | — | | `$helpmate.setConversationCustomAttributes(attributes)` | Conversation | Attach data to the current conversation rather than to the person — order id, page the chat started from, plan at the time of asking. | If the map is empty. | | `$helpmate.deleteConversationCustomAttribute(key)` | Conversation | Remove one conversation attribute. | If the key is empty. | | `$helpmate.toggle("open" \| "close")` | Widget | Open or close the widget. Called with no argument it flips the current state. | — | | `$helpmate.showNewMessage()` | Widget | Open the widget straight into the composer — the right target for your own "Talk to us" button. | — | | `$helpmate.openChat({ message, destination })` | Widget | Open the widget on the visitor's conversation — their most recent one, or a new one if they have never written. `message` prefills the composer and is never sent for them; `destination: "home"` opens the home screen instead. This is what the `?hm_chat=1` link calls. | — | | `$helpmate.toggleBubbleVisibility("show" \| "hide")` | Widget | Show or hide the launcher bubble while leaving the widget usable through your own trigger. | — | | `$helpmate.setLocale(locale)` | Widget | Switch the widget language at runtime, e.g. "en", "fr", "pt_BR". | — | | `$helpmate.setColorScheme("light" \| "dark" \| "auto")` | Widget | Follow your own theme switch. Anything else falls back to "light". | — | | `$helpmate.popoutChatWindow()` | Widget | Open the conversation in a separate browser window. | — | | `$helpmate.reset()` | Widget | Forget the current visitor and start clean. Call this on logout — otherwise the next person at that browser inherits the previous conversation. | — | ## Events All dispatched on `window`. | Event | Fired when | | --- | --- | | `helpmate:ready` | The widget has booted and window.$helpmate is safe to call. Every identify call belongs inside this listener — calling setUser before it is a no-op. | | `helpmate:on-show` | The visitor opened the widget. | | `helpmate:on-close` | The visitor closed the widget. | | `helpmate:on-message` | A message was sent or received. | | `helpmate:on-start-conversation` | The visitor started their first conversation in this session. | | `helpmate:error` | An SDK call failed. `event.detail` carries the API response — a rejected identify (bad property type, failed verification) surfaces here. | `helpmate:error` is the one worth wiring up during development — a rejected identify call (bad property type, failed verification, unknown key in strict mode) surfaces there with the API's message, and is otherwise invisible. ```javascript window.addEventListener('helpmate:error', function (event) { console.warn('Helpmate rejected a call', event.detail); }); ``` ## Opening the widget from your own UI Hide the launcher at boot and drive it yourself: ```javascript window.helpmateSettings = { hideMessageBubble: true }; // later, on your own "Talk to us" button document.querySelector('#support').addEventListener('click', function () { window.$helpmate?.showNewMessage(); }); ``` `showNewMessage()` opens straight into the composer. `openChat()` opens on the visitor's conversation — the most recent one, or a new one if they have never written — and takes a message to prefill: ```javascript window.$helpmate?.openChat({ message: 'A question about invoice ' + invoice.number }); ``` `toggle('open')` opens to whatever screen the visitor last had. From somewhere that cannot run JavaScript — a mobile app, an email footer — link to your help center's `/chat` page instead, or to `?hm_chat=1` on a page of your own that carries the widget; both call `openChat()` on arrival. See Live chat. ## Conversation attributes Person-level properties describe who someone is; conversation attributes describe *this* conversation — the order they are asking about, the page they started from, the plan they were on at the time. ```javascript window.$helpmate.setConversationCustomAttributes({ order_id: 'A-10432', started_from: window.location.pathname, }); ``` They show on the conversation in the agent inbox and do not affect contact-level targeting. ## Boot settings Set before the loader runs; read once. Where a key also has a control in **Settings → Website widget**, the dashboard wins — see Live chat. | Key | Values | What it does | | --- | --- | --- | | `locale` | "en", "fr", … | Widget language. | | `useBrowserLanguage` | true \| false | Take the language from the browser instead of `locale`. | | `position` | "right" \| "left" | Which side the launcher sits on. | | `type` | "standard" \| "expanded_bubble" | Launcher shape. `expanded_bubble` shows `launcherTitle` beside the icon. | | `launcherTitle` | string | Label on the expanded launcher. | | `hideMessageBubble` | true \| false | Boot with the launcher hidden — pair with your own trigger calling `showNewMessage()`. | | `darkMode` | "light" \| "dark" \| "auto" | Colour scheme at boot. | | `widgetStyle` | "standard" \| "flat" | Widget chrome. | | `baseDomain` | string | Cookie domain, e.g. ".acme.com", when the widget spans subdomains. | | `containerId` | string | Id of the element the widget mounts into. |