Install
From nothing installed to fully working — the script tag, identify, triggers, React, your knowledge base, and exactly what happens to your users' data.
This is the whole install, start to finish. Four parts: the script tag (two minutes, and enough on its own for several Moments to start firing), the wiring — identify(), trigger(), and your knowledge base — which is what makes Sarah specific to your product, React and Next.js if that's your stack, and privacy for whoever needs to sign off on what you're sending us.
No build step, no npm install. The SDK is a script tag and a handful of global calls.
The script tag
Add it once, in <head> or before </body>:
<script src="https://www.usersay.ai/sdk/v1/usersay.js"
data-project="your-project-slug" async></script>Replace your-project-slug with your project's slug — find it in your dashboard under Install.
That tag alone starts several Moments: welcome on a first visit, pricing_visit when the URL matches /pricing, return_visit when the SDK sees a session gap. See the eleven Moments for the full list and how much you have to build for which ones cost you code.
Because the script loads async, your own identify() and trigger() calls may run before it finishes loading. The SDK queues every call made before init and replays them once loaded — you don't need a load callback.
Versioning. The /sdk/v1/ path pins your integration to the v1 API contract — the identify() field shape, the trigger() / openPrompt() signatures, and the onComplete() callback are guaranteed stable. Breaking changes ship under /sdk/v2/ and never silently retrofit v1 customers. The bare https://www.usersay.ai/usersay-sdk.js URL also works (it tracks latest) but is not recommended for production — pin to a major version.
Script tag attributes
| Attribute | Required | Description |
|---|---|---|
data-project | Yes | Your project slug (e.g. mymap) |
data-theme | No | light | dark. Forces the widget theme instead of auto-detecting. |
data-offset-bottom | No | Pixels to lift chat panels above your bottom-anchored UI (a chat input, a sticky bar). The draggable launcher stays 10px from the bottom-right corner by default. |
Light and dark mode
The widget resolves its theme once on init: data-theme on the script tag wins, then <html class="dark"> / <html class="light"> (the Tailwind and shadcn convention), then <html data-theme> (daisyUI), then the measured background colour of <body> — dark below 0.5 relative luminance, walking up the DOM if the background is transparent — and finally prefers-color-scheme. Class detection sits above measurement because Tailwind hosts set <html class="dark"> before paint, while a measured background can briefly compute to the system default before your theme toggle hydrates.
In dark mode the widget drops the saturated green header bar — it reads as an "advert" against a near-black UI — for an elevated gray surface. Green survives only as an accent on the avatar dot, send button, and Reply CTA.
If auto-detection still picks wrong, pin it:
<script src="https://www.usersay.ai/sdk/v1/usersay.js"
data-project="your-project-slug"
data-theme="dark"
async></script>Verify the install
From any terminal — no browser, no user login. First send a self-test event, which marks the install as reached even without a real browser origin:
curl -X POST https://www.usersay.ai/api/sdk/event \
-H 'content-type: application/json' \
-d '{"project":"YOUR_SLUG","uid":"verify-'$(date +%s)'","event":"identify","source":"agent-verify","metadata":{"plan":"pro"}}'Expect {"ok":true}. Then read back the install topology — the machine-checkable acceptance test:
curl "https://www.usersay.ai/api/sdk/status?project=YOUR_SLUG"{
"script": { "installed": true },
"identify": { "seen": true, "hasPlan": true },
"moments": [{ "id": "cancel", "firing": true, "needsHostCode": true }]
}A Moment you wired reads "firing": true once you've exercised it. Still false means your trigger point isn't being hit. The dashboard's Install banner also flips to "Connected" once the SDK reaches the server.
Telling her who the user is
Call UserSay.identify() after the user logs in. Only uid is required — pass whatever else your app already has and skip the rest. Don't invent values your app doesn't track. Every other method (trigger, openPrompt, open) no-ops with a console warning until identify() has run.
// Minimum
UserSay.identify({ uid: currentUser.id });
// Pass whatever else your app already has — the table below says what each does
UserSay.identify({
uid: currentUser.id, name: currentUser.name, email: currentUser.email,
signupAt: currentUser.createdAt, plan: currentUser.plan,
trialing: currentUser.isTrialing, subscribed: currentUser.isSubscribed,
credits: currentUser.credits, role: currentUser.role,
avatarUrl: currentUser.avatarUrl, locale: currentUser.locale,
country: currentUser.country, traits: { /* any extra custom fields */ },
});| Field | Type | Required | What it unlocks |
|---|---|---|---|
uid | string | Yes | Your user's stable ID — the only required field. Everything keys off it: dedup, session attribution, deletion requests. |
name | string | Recommended | Sarah greets the user by name; the dashboard shows a person instead of u_xyz. |
email | string | Recommended | The only way to follow up with a customer about something they said. Sent plaintext over HTTPS — see Privacy. Don't pre-hash. Omit entirely if you'd rather not send it. |
signupAt | string | Recommended | ISO 8601. Lets Sarah tell "signed up ten minutes ago" from "month-old user" — it meaningfully changes her opener and follow-ups. |
plan | string | Recommended (if applicable) | The field that distinguishes a paying user. Send your real tier name exactly as you'd name it internally — free, pro, max, team. It routes behaviour Moments (return_visit vs return_paid_user), and its changes over time are how UserSay detects an upgrade and prices it. Collapsing paid tiers into one label (isPaid ? 'pro' : 'free') makes a pro → max upsell invisible. Skip only if your product is genuinely single-tier. |
subscribed | boolean | Recommended (if applicable) | True for paid subscribers. Routes churn-prevention Moments. |
trialing | boolean | Recommended (if applicable) | True during a trial. Sarah probes trial-conversion friction with it, and UserSay uses it to keep a trial from being counted as revenue. Send it whenever your plan already reads as paid during a trial — otherwise starting a trial looks identical to buying. |
role | string | Recommended (B2B only) | The user's role inside the customer org (admin, member). Drives B2B segmentation and lets Sarah tailor questions to decision-maker vs end-user. |
credits | number | Recommended (if applicable) | Stored on the end-user record, and context for credit-related Moments. Pass it only if your product uses credits — most B2B SaaS doesn't have them; don't fabricate one. |
avatarUrl | string | No | Cosmetic — shown in the dashboard. |
locale | string | No | BCP-47 language tag — Sarah opens in this language. Falls back to <html lang>, then navigator.language, then en. |
country | string | No | ISO 3166-1 alpha-2. Filled from the request IP if omitted. |
traits | object | No | Free-form bag for custom fields. Unknown top-level fields are ignored, so put extras here. |
The SDK also resolves and sends the browser's timezone on every identify() — you never pass it.
plan and trialing are money fields, not just context
Most fields here only shape what Sarah says. These two also decide what gets counted. UserSay reads the plan you report on each login as a timeline, and a step up in it is how an upgrade is detected and priced — so an approximation that's harmless in conversation ("everyone paying is pro") silently erases your real upsells, and a trial reported as a paid plan books revenue that hasn't happened yet.
If you only tidy up two fields on this page, make them these.
The rule of thumb: if the field is true for your product and you have the value, pass it. The more relevant context Sarah has, the better her opener and follow-ups. But "relevant" is product-specific — credits matters for a credit-billed AI product, subscribed for SaaS, role for B2B. Fabricated values just add noise.
Firing moments from your code
Moments with trigger type sdk_manual fire when you call UserSay.trigger() at the right spot:
// Out of credits — fire before showing the out-of-credits state
if (currentUser.credits <= 0) UserSay.trigger('credits_exhausted');
// Paywall exit — fire when the user dismisses an upgrade modal
closeButton.addEventListener('click', () => {
closeModal();
UserSay.trigger('paywall_shown');
});
// First use — fire after the user completes their first core action
await createDiagram(params);
UserSay.trigger('first_use');For the Open feedback Moment — and any other user_initiated Moment — use openPrompt() instead. It opens the chat directly and skips dedup, because the user explicitly asked to talk:
feedbackBtn.addEventListener('click', () => {
UserSay.openPrompt('open_feedback');
});Register onComplete for client-side UI feedback — a toast, a balance refresh — when the interview finishes:
UserSay.onComplete(function(data) {
if (data.reward?.type === 'credits') {
showToast(`Thanks! ${data.reward.value} credits added ✨`);
refreshCreditBalance(); // re-fetch from your server
}
});data field | Type | Description |
|---|---|---|
sessionId | string | Unique session ID for this interview. |
reward | { type, value } | null | Reward configured for this Moment. |
All together, that's the complete vanilla install:
<script src="https://www.usersay.ai/sdk/v1/usersay.js"
data-project="mymap" async></script>
<script>
UserSay.identify({ uid: currentUser.id, name: currentUser.name,
email: currentUser.email, signupAt: currentUser.createdAt,
subscribed: currentUser.isSubscribed });
UserSay.onComplete((d) => d.reward && showToast(`+${d.reward.value} ${d.reward.type} ✨`));
if (currentUser.credits <= 0) UserSay.trigger('credits_exhausted');
feedbackBtn.onclick = () => UserSay.openPrompt('open_feedback');
</script>When a Moment won't fire again
The SDK won't show the same Moment to the same user twice. The defaults:
- Completed — once a user finishes an interview for a Moment, it never shows again.
- Dismissed — if they close without completing, that Moment is suppressed for 7 days.
Both are configurable per Moment in the dashboard. State lives in localStorage under usersay:{project}:{uid}:pb:{momentId}:*, and identify() merges in the server's record of what this uid has already seen — so someone who answered on their laptop isn't asked again on their phone.
openPrompt() deliberately bypasses all of it. It's rate-limited instead: 50 opens per hour, per Moment.
The full SDK surface
| Method | Description |
|---|---|
UserSay.identify(user) | Identify the current user. Call after login, before anything else. |
UserSay.trigger(momentId, opts?) | Fire a Moment. Opens the widget if dedup passes. opts.openMode ∈ 'bubble' | 'chat' overrides the dashboard default. |
UserSay.openPrompt(momentId, opts?) | Open a Moment directly, bypassing dedup — for user-initiated entry points. Rate-limited to 50/hour per Moment. |
UserSay.onComplete(fn) | Register a callback for interview completion. |
UserSay.open(momentId) | Force-open the centered modal directly (no bubble). Bypasses dedup — use sparingly. |
UserSay.close() | Close whichever UI is currently open (bubble / mini chat / modal). |
React and Next.js
Same SDK, no extra packages. Load it with next/script, then call the global from your components.
// app/layout.tsx
import Script from 'next/script';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return <html><body>
{children}
<Script src="https://www.usersay.ai/sdk/v1/usersay.js"
data-project="your-project-slug" strategy="afterInteractive" />
</body></html>;
}Identify from a Client Component once auth resolves, and register onComplete in the same place. Drop <UserSayInit /> inside your authenticated layout:
'use client';
import { useEffect } from 'react';
import { useUser } from '@/lib/auth'; // your auth hook
export function UserSayInit() {
const user = useUser();
useEffect(() => {
if (!user) return;
// Pass whatever your app has; plan / credits / role only if you have them
window.UserSay?.identify({
uid: user.id, name: user.name, email: user.email,
signupAt: user.createdAt, subscribed: user.isSubscribed,
});
}, [user?.id]);
useEffect(() => {
window.UserSay?.onComplete((data) => {
if (data.reward?.type === 'credits') {
toast(`Thanks! ${data.reward.value} credits added ✨`);
mutate('/api/me'); // re-fetch user to update balance
}
});
}, []);
return null;
}Triggers are one-liners from any Client Component, at the point the event actually happens:
'use client';
export function CancelButton({ onCancel }: { onCancel: () => void }) {
// Your flow still owns the cancellation; she rides alongside for the reason.
// To hand her the outcome instead, see /docs/keep — it's a different call.
const click = () => { onCancel(); window.UserSay?.trigger('cancel'); };
return <button onClick={click}>Cancel subscription</button>;
}
export function FeedbackButton() {
// User-initiated entry points use openPrompt — it skips dedup
return <button onClick={() => window.UserSay?.openPrompt('open_feedback')}>Feedback</button>;
}TypeScript types
Add a declaration file so TypeScript knows about window.UserSay:
// types/usersay.d.ts
interface UserSayUser {
uid: string; // required
name?: string;
email?: string;
avatarUrl?: string;
signupAt?: string; // ISO 8601
plan?: string;
trialing?: boolean;
subscribed?: boolean;
credits?: number; // only if your product uses credits
role?: string; // B2B role inside the customer org
locale?: string; // BCP-47, e.g. 'zh-CN' — auto-detected if omitted
country?: string; // ISO 3166-1 alpha-2 — detected from IP if omitted
traits?: Record<string, unknown>;
}
interface UserSayTriggerOpts {
openMode?: 'bubble' | 'chat';
}
interface UserSayCompletionData {
sessionId: string;
reward: { type: string; value: string } | null;
}
interface UserSaySDK {
identify(user: UserSayUser): void;
trigger(momentId: string, opts?: UserSayTriggerOpts): void;
openPrompt(momentId: string, opts?: UserSayTriggerOpts): void;
onComplete(fn: (data: UserSayCompletionData) => void): void;
open(momentId: string): void;
close(): void;
}
declare global {
interface Window {
UserSay?: UserSaySDK;
}
}Teaching her about your product
Halfway through a conversation a user asks Sarah a plain factual question: "how many credits does Pro get?" or "do you support SVG export?" or "what's your refund policy?" A confident wrong answer about your pricing destroys trust faster than anything else she could do — so instead of guessing, she looks it up.
The knowledge base is what she looks it up in. You point UserSay at your product — marketing site, docs, pricing page — and it reads them and distils the facts Sarah might need, stripped of the layout and marketing prose around them. You don't write it by hand and you don't maintain a separate FAQ.
Once one is configured, Sarah gains a lookup_knowledge tool. She calls it silently before answering any factual question — no "let me check" preamble — and two rules govern what she does with the result:
- She answers in her own words, never verbatim. It's reference she reads, not a script she reads out.
- If it's not in there, she says so. She admits she doesn't know and offers to check with the team. Admitting uncertainty costs you a little; a confident wrong answer costs you the user.
This is a built-in capability, not an Action. lookup_knowledge is read-only and runs inside UserSay — nothing to build, nothing to connect, no server of yours involved. It's Layer 0, her words. (It's also why lookup_knowledge is a reserved tool name your own Action MCP can't use.)
What to put in it — the things users actually ask at the edge of a decision:
- Pricing and plan limits — tiers, what each includes, credit allowances, what happens at the limit.
- Refund and cancellation policy — the window, what's refundable, how to cancel.
- Capabilities and limits — supported formats, integrations, the "can it do X?" questions.
- What makes you different — the honest one-liner on why someone picks you over the obvious alternative.
Keep it current; a stale price is a confident wrong answer waiting to happen. And treat it as internal reference, not user-facing copy — Sarah draws on it, she doesn't recite it.
The obvious win is support: correct answers without a ticket. The less obvious one is conversion. Most of what blocks a sale is a factual question the user is afraid to ask a salesperson — is there a free tier? what happens to my data if I cancel? Answered accurately mid-conversation, the objection dissolves before it becomes a reason to leave. It's what lets the Grow and Keep playbooks run on facts instead of hope.
Privacy and data handling
UserSay processes end-user identity passed via UserSay.identify() and conversation transcripts collected through Sarah. This section documents what we store, how, and what controls you have.
What we store
For each end-user identified by uid, we persist whatever subset of these fields you pass to identify():
- Identity:
uid,name,email,avatarUrl - Lifecycle signals:
signupAt,plan,subscribed,trialing,credits,role - Locale:
locale,country,timezone—timezoneis resolved by the SDK from the browser, never passed by you - Custom: anything in
traits
Plus, for each interview session: the full conversation transcript, the conversation Sarah had, momentId, timestamps, and the endUser snapshot at session-creation time.
Email is optional — and plaintext over HTTPS is the standard
email is never required. Without it the dashboard shows the user as u_xyz — UserSay works the same, you just lose the ability to email a customer back about something they said.
If you do pass email, send it plaintext over HTTPS. This is the same model used by every major B2B analytics product:
| Product | Email transmission default |
|---|---|
| Segment | Plaintext over HTTPS |
| Mixpanel | Plaintext over HTTPS |
| PostHog | Plaintext over HTTPS — Hash Properties is an opt-in CDP transformation that runs after receipt, not before transmission (source) |
| Intercom | Plaintext over HTTPS |
| Amplitude | Plaintext over HTTPS |
Don't pre-hash email yourself. Unsalted SHA256(email) is reversible by rainbow table for common email domains and, per the EDPB Pseudonymisation Guidelines (Jan 2025) and the CJEU EDPS v SRB ruling (Sep 2025), still constitutes personal data under GDPR — so the hash buys you noise but not compliance, and you lose the ability to ever look up that customer.
The genuinely different scenario is ad-tech conversion APIs (Google / Meta / Bing CAPI). Those do require hashing, but for a different reason: cross-party customer matching where neither side can see the other's PII. That doesn't apply to first-party product analytics like UserSay.
In transit and at rest
- In transit: TLS 1.2+ everywhere (Vercel + Cloudflare default). Bare
data:or unencrypted URLs are not accepted. - At rest: Postgres encryption at rest (AES-256 via Neon, the underlying provider).
- Database access: scoped to the UserSay application service account — no shared databases with other tenants.
Deletion
End-users have a GDPR right to erasure and a CCPA equivalent. To remove a specific end-user from a project, email [email protected] with the (project, uid) pair — we wipe within 72 hours. The deletion cascades: end-user identity, all sessions, transcripts, and the analysis derived from them for that uid.
Data Processing Agreement
If your customers ask for one, request a DPA at [email protected]. Standard contract, signed within 1 business day.
What we don't do
- We don't sell data to third parties.
- We don't share your project's data with other UserSay customers.
- We don't use end-user transcripts to train external foundation models. Insights extraction calls an LLM provider in a stateless, no-retention configuration (Anthropic ZDR where applicable).
- We don't track end-users across projects —
uidis namespaced per project.
Subprocessors
| Vendor | Purpose | Region |
|---|---|---|
| Vercel | Application hosting, edge | US/EU |
| Neon | Postgres (encrypted at rest) | US |
| Anthropic | LLM inference for Sarah | US (zero-data-retention) |
| Sentry | Error monitoring (no PII transmitted) | US |
| Cloudflare | DDoS / WAF | Global |
Questions
Email [email protected]. Privacy concerns get a same-day response.
Next: Actions — everything above is Layer 0, her words, and it's free. Actions are Layer 1, her hands: an MCP server you run, so she can grant credits, build a checkout, or issue a refund instead of only talking about them.
Hide the launcher on specific pages
In Widget → Appearance, turn on Show the launcher, then add paths under Hide on these pages, one per line. For example:
/checkout
/editor/*/checkout matches only that page. /editor/* includes /editor and all its
subpages. Matching is case-sensitive and ignores trailing slashes, query strings,
and hashes. Use paths rather than full URLs; only a trailing /* wildcard is
supported. Leave the list empty to show the launcher on every page.
The launcher follows page changes automatically, including SPA navigation. These rules only hide the corner button: open chats remain open, and automatic messages and your own chat buttons continue working. Turning Show the launcher off hides the button everywhere. Saved settings apply when your site next loads the SDK after its configuration cache refreshes.
Hide the launcher on mobile
In Widget → Appearance, enable Hide on mobile to hide the corner button on screens narrower than 768px. It is off by default and combines with your page exclusions. The SDK follows screen-size changes automatically; no host code is needed. This uses viewport width, so narrow desktop windows are included and landscape phones at 768px or wider are not. Open chats and automatic messages continue working.