# Usero developer docs Usero turns user feedback into shipped code. This file contains every page of the developer docs (https://usero.io/docs) as one markdown document. Per-page index: https://usero.io/llms.txt --- # Send your first feedback in 5 minutes One POST. No SDK, no API key. Works from any language, any platform. ## Step 1: Get your clientId Your clientId identifies your project. It starts with `client_`. - Logged in? Every snippet on this page already shows your real clientId. - Logged out? Snippets show an anonymous clientId minted for your browser. That id is real and works right now: send the POST below and the feedback lands in an inbox tied to your browser. [Sign up](/signup) to keep that id and its feedback under your account. - Snippets showing a placeholder instead of a `client_` id? You are reading a cached or text copy of this page, or your browser blocked cookies. [Sign up free](/signup) and your clientId is on the first screen. More detail: [Find your clientId](/docs/find-your-client-id). ## Step 2: Send a POST ```bash title=curl curl -X POST https://usero.io/api/feedback \ -H "Content-Type: application/json" \ -d '{ "clientId": "YOUR_CLIENT_ID", "rating": 4, "comment": "First feedback from the quickstart" }' ``` ```javascript title=JavaScript const res = await fetch('https://usero.io/api/feedback', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ clientId: 'YOUR_CLIENT_ID', rating: 4, comment: 'First feedback from the quickstart', }), }) console.log(await res.json()) // { success: true, feedbackId: "..." } ``` ```swift title=Swift import Foundation #if canImport(FoundationNetworking) import FoundationNetworking // Linux and online playgrounds #endif var request = URLRequest(url: URL(string: "https://usero.io/api/feedback")!) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try JSONSerialization.data(withJSONObject: [ "clientId": "YOUR_CLIENT_ID", "rating": 4, "comment": "First feedback from my Mac app", ]) let (data, _) = try await URLSession.shared.data(for: request) print(String(data: data, encoding: .utf8)!) // {"success":true,"feedbackId":"..."} ``` ```python title=Python import requests res = requests.post( "https://usero.io/api/feedback", json={ "clientId": "YOUR_CLIENT_ID", "rating": 4, "comment": "First feedback from the quickstart", }, ) print(res.json()) # {'success': True, 'feedbackId': '...'} ``` A successful response is: ```json { "success": true, "feedbackId": "abc123" } ``` `rating` (1 to 4: Terrible, Bad, Good, Amazing) and `comment` are each optional, but include at least one of them. Full field list: [POST /api/feedback reference](/docs/api/feedback). ## Step 3: See it land Open your [dashboard](/) and check the feedback inbox. Your POST shows up within a second or two. If you sent an `environment` value, switch the environment picker to that value to see it. ## Or let your coding agent do it Paste this prompt into Cursor, Claude Code, or any coding agent and it will build a feedback form wired to your account. ```text title=Agent prompt # Task: Add Feedback Collection to This Project Build a feedback UI that submits to Usero's API. The goal is to collect user satisfaction ratings and optional comments. ## What to build A feedback form or prompt with: - A 4-level rating (1 = Terrible, 2 = Bad, 3 = Good, 4 = Amazing) - An optional comment text field - A submit button that POSTs to the Usero API ## API Details POST https://usero.io/api/feedback Content-Type: application/json ### Request body { "clientId": "YOUR_CLIENT_ID", // required — your client ID "rating": 3, // required — 1 to 4 "comment": "User's feedback", // optional "environment": "production", // optional — helps filter by env in dashboard "pageUrl": "https://...", // optional — auto-detect from window.location "pageTitle": "...", // optional — auto-detect from document.title "userEmail": "user@example.com" // optional } ### Response 200: { "success": true, "feedbackId": "abc123" } ## Working example curl -X POST https://usero.io/api/feedback \ -H "Content-Type: application/json" \ -d '{ "clientId": "YOUR_CLIENT_ID", "rating": 3, "comment": "Love the new dashboard!", "pageUrl": "https://yourapp.com/page", "environment": "production" }' ## Tips - Auto-detect pageUrl and pageTitle from the browser so you get per-page feedback tracking. - Set environment to distinguish production feedback from staging/dev. - Show a success message after submission. Only clientId and rating are required. ``` ## Next steps - [POST /api/feedback reference](/docs/api/feedback): every field, response shape, and error code - [Screenshot uploads](/docs/api/screenshots): attach images to feedback - [Drop-in widget](/docs/widget): React, vanilla JS, or a script tag, no form to build - [Integrations](/docs/integrations): GitHub, Slack, email, WordPress, and more --- # API reference: POST /api/feedback Create one feedback item. This is the same endpoint the widget uses, and it is open to direct calls from any language. ## Request ```text title=Endpoint POST https://usero.io/api/feedback Content-Type: application/json ``` - **CORS:** `Access-Control-Allow-Origin: *`. Call it straight from browsers, mobile apps, desktop apps, or servers. - **Auth:** the `clientId` in the body identifies your project. No API key, no auth header. - **Abuse control:** if your client has a domain allowlist configured in Settings, requests from other origins get a `403`. New clients have no allowlist, so everything is accepted. ## Required fields `clientId` is always required. `rating` and `comment` are each optional, but every submission must include at least one of them: a `rating` (1 to 4), a non-empty `comment`, or both. Everything else is optional. ## Fields | Field | Type | Required | Description | | ----------------- | ------- | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `clientId` | string | yes | Your project id, starts with `client_`. See [Find your clientId](/docs/find-your-client-id). | | `rating` | integer | one of rating / comment | 1 to 4: 1 Terrible, 2 Bad, 3 Good, 4 Amazing. | | `comment` | string | one of rating / comment | Free-text feedback. Must be non-empty if no rating is sent. | | `userEmail` | string | no | The submitter's email, must be a valid address. Lets you reply and groups feedback by person in the dashboard. An empty string is treated as absent. | | `pageUrl` | string | no | URL the feedback was given on. If omitted, the server falls back to the `Referer` header. | | `pageTitle` | string | no | Title of the page the feedback was given on. | | `referrer` | string | no | The page the user arrived from. | | `environment` | string | no | Optional. Omit it unless you separate environments like `production` and `staging`; feedback without it lands in your default inbox. | | `screenshots` | array | no | Screenshot objects returned by [POST /api/screenshots](/docs/api/screenshots). Each item: `fileName` (string), `url` (string), `fileSize` (number), `mimeType` (string), `width` (number, optional), `height` (number, optional). | | `metadata` | object | no | Arbitrary JSON attached to the feedback, 10KB max when serialized. Good place for app version, OS, build number, or feature flags. | | `sessionReplayId` | string | no | 1 to 128 characters. Links the feedback to a session replay recorded by [@usero/sdk session replay](/docs/widget/session-replay). | | `replayOffsetMs` | integer | no | Non-negative millisecond offset into the linked replay at which the feedback was given. | ## Examples Minimal submission: ```bash title=curl curl -X POST https://usero.io/api/feedback \ -H "Content-Type: application/json" \ -d '{"clientId": "YOUR_CLIENT_ID", "rating": 3}' ``` ```javascript title=JavaScript await fetch('https://usero.io/api/feedback', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ clientId: 'YOUR_CLIENT_ID', rating: 3 }), }) ``` ```swift title=Swift var request = URLRequest(url: URL(string: "https://usero.io/api/feedback")!) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try JSONSerialization.data(withJSONObject: [ "clientId": "YOUR_CLIENT_ID", "rating": 3, ]) let (data, _) = try await URLSession.shared.data(for: request) ``` ```python title=Python import requests requests.post( "https://usero.io/api/feedback", json={"clientId": "YOUR_CLIENT_ID", "rating": 3}, ) ``` Full-field submission: ```bash title=curl curl -X POST https://usero.io/api/feedback \ -H "Content-Type: application/json" \ -d '{ "clientId": "YOUR_CLIENT_ID", "rating": 2, "comment": "Export to CSV times out on large projects", "userEmail": "jamie@example.com", "pageUrl": "https://yourapp.com/projects/42/export", "pageTitle": "Export project", "referrer": "https://yourapp.com/projects/42", "environment": "staging", "metadata": { "appVersion": "2.4.1", "os": "macOS 15.2", "build": 1842 } }' ``` ```javascript title=JavaScript await fetch('https://usero.io/api/feedback', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ clientId: 'YOUR_CLIENT_ID', rating: 2, comment: 'Export to CSV times out on large projects', userEmail: 'jamie@example.com', pageUrl: window.location.href, pageTitle: document.title, referrer: document.referrer, environment: 'staging', metadata: { appVersion: '2.4.1', os: navigator.platform, build: 1842 }, }), }) ``` ```swift title=Swift var request = URLRequest(url: URL(string: "https://usero.io/api/feedback")!) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try JSONSerialization.data(withJSONObject: [ "clientId": "YOUR_CLIENT_ID", "rating": 2, "comment": "Export to CSV times out on large projects", "userEmail": "jamie@example.com", "environment": "staging", "metadata": [ "appVersion": Bundle.main.infoDictionary?["CFBundleShortVersionString"] ?? "unknown", "os": ProcessInfo.processInfo.operatingSystemVersionString, ], ]) let (data, _) = try await URLSession.shared.data(for: request) ``` ```python title=Python import requests requests.post( "https://usero.io/api/feedback", json={ "clientId": "YOUR_CLIENT_ID", "rating": 2, "comment": "Export to CSV times out on large projects", "userEmail": "jamie@example.com", "environment": "staging", "metadata": {"appVersion": "2.4.1", "build": 1842}, }, ) ``` For native apps, `metadata` is the place for app version, OS version, and build number, so every report arrives with the context you need to reproduce it. ## Response Success: ```json { "success": true, "feedbackId": "abc123" } ``` ## Errors | Status | Body | Meaning and fix | | ------ | ------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | | 400 | `{"error": "Invalid data provided", "issues": {"rating": ["..."]}}` | Validation failed. `issues` names each failing field. The most common cause: neither `rating` nor a non-empty `comment` was sent. | | 400 | `{"error": "Metadata too large (max 10KB)"}` | Serialized `metadata` exceeds 10KB. Trim it. | | 403 | `{"error": "Domain not allowed"}` | Your client has a domain allowlist and this request's origin is not on it. Add the origin in Settings, or clear the allowlist. | | 500 | `{"error": "Internal server error"}` | Something broke on our side. Safe to retry. | ## Related - [Screenshot uploads](/docs/api/screenshots): two-step flow to attach images - [Session replay](/docs/widget/session-replay): where `sessionReplayId` comes from - [Find your clientId](/docs/find-your-client-id) --- # API reference: screenshot uploads Attaching an image to feedback is a two-step flow: upload the file here first, then include the returned screenshot object in the `screenshots` array of your [POST /api/feedback](/docs/api/feedback) call. ## Request ```text title=Endpoint POST https://usero.io/api/screenshots Content-Type: multipart/form-data ``` - **CORS:** `Access-Control-Allow-Origin: *`, callable directly from browsers and apps. - **Auth:** the `clientId` form field identifies your project. No API key. ### Form fields | Field | Type | Required | Description | | ------------ | ------ | -------- | --------------------------------------------------- | | `screenshot` | file | yes | The image. Any `image/*` MIME type, 10MB max. | | `clientId` | string | yes | Your project id. Must belong to an existing client. | One file per request. The widget allows up to 3 screenshots per feedback; if you need several, send several uploads. ## Examples ```bash title=curl curl -X POST https://usero.io/api/screenshots \ -F "screenshot=@./bug.png" \ -F "clientId=YOUR_CLIENT_ID" ``` ```javascript title=JavaScript const form = new FormData() form.append('screenshot', fileInput.files[0]) form.append('clientId', 'YOUR_CLIENT_ID') const res = await fetch('https://usero.io/api/screenshots', { method: 'POST', body: form, // do NOT set Content-Type yourself, the browser adds the boundary }) const { screenshot } = await res.json() ``` ```swift title=Swift let boundary = UUID().uuidString var request = URLRequest(url: URL(string: "https://usero.io/api/screenshots")!) request.httpMethod = "POST" request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") var body = Data() body.append("--\(boundary)\r\nContent-Disposition: form-data; name=\"clientId\"\r\n\r\nYOUR_CLIENT_ID\r\n".data(using: .utf8)!) body.append("--\(boundary)\r\nContent-Disposition: form-data; name=\"screenshot\"; filename=\"bug.png\"\r\nContent-Type: image/png\r\n\r\n".data(using: .utf8)!) body.append(pngData) // your image bytes body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!) request.httpBody = body let (data, _) = try await URLSession.shared.data(for: request) ``` ```python title=Python import requests res = requests.post( "https://usero.io/api/screenshots", files={"screenshot": open("bug.png", "rb")}, data={"clientId": "YOUR_CLIENT_ID"}, ) print(res.json()) ``` ## Response ```json { "success": true, "screenshot": { "fileName": "client_abc/1717740000000-x7k2p.png", "url": "https://usero.io/api/screenshots/client_abc/1717740000000-x7k2p.png", "fileSize": 48211, "width": 1280, "height": 800, "mimeType": "image/png" } } ``` ## Attach it to feedback Pass the whole `screenshot` object inside the `screenshots` array when you submit the feedback: ```bash title=curl curl -X POST https://usero.io/api/feedback \ -H "Content-Type: application/json" \ -d '{ "clientId": "YOUR_CLIENT_ID", "comment": "Button overlaps the footer, screenshot attached", "screenshots": [ { "fileName": "client_abc/1717740000000-x7k2p.png", "url": "https://usero.io/api/screenshots/client_abc/1717740000000-x7k2p.png", "fileSize": 48211, "width": 1280, "height": 800, "mimeType": "image/png" } ] }' ``` ```javascript title=JavaScript await fetch('https://usero.io/api/feedback', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ clientId: 'YOUR_CLIENT_ID', comment: 'Button overlaps the footer, screenshot attached', screenshots: [screenshot], // the object from the upload response }), }) ``` ## Errors | Status | Body | Meaning and fix | | ------ | ------------------------------------------ | ------------------------------------------------------------------------------------- | | 400 | `{"error": "Screenshot file is required"}` | The `screenshot` form field is missing. | | 400 | `{"error": "Client ID is required"}` | The `clientId` form field is missing. | | 400 | `{"error": "File must be an image"}` | The MIME type is not `image/*`. | | 400 | `{"error": "File too large (max 10MB)"}` | The file exceeds 10MB. | | 403 | `{"error": "Domain not allowed"}` | Your client has a domain allowlist and this origin is not on it. | | 404 | `{"error": "Invalid client ID"}` | No client with that id exists. Check [Find your clientId](/docs/find-your-client-id). | | 500 | `{"error": "Failed to upload screenshot"}` | Something broke on our side. Safe to retry. | --- # Feedback widget (@usero/sdk) A drop-in feedback button for the web. Vanilla JS, React component, or a ` ``` `unpkg` and `jsDelivr` both serve the IIFE bundle automatically. > [!WARNING] **Omit the `environment` option for your default environment.** Do not pass a placeholder like `"no-env"` or > `"default"`. The dashboard treats an absent environment as the default; a placeholder string creates a separate environment and > your feedback will not appear in the default inbox. ## Identify your users (optional) The widget works fully anonymous. If you have a logged-in user, identifying them lights up "who is this person" on session replays and lets you filter feedback by email in the dashboard. React: pass the `user` prop (omit or pass `null` for logged-out visitors). The widget re-identifies automatically when the prop changes: ```tsx title=React ``` Vanilla: pass a `getUser` callback, called at session start: ```ts title=Vanilla initUseroFeedbackWidget({ clientId: 'YOUR_CLIENT_ID', getUser: () => (currentUser ? { id: currentUser.id, email: currentUser.email } : null), }) ``` ## Options | Option | Type | Default | Description | | ---------------------- | ------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `clientId` | `string` | required | Your Usero client id. See [Find your clientId](/docs/find-your-client-id). | | `position` | `'left' \| 'right'` | `'right'` | Which side of the viewport the button sits on. | | `theme` | `Partial` | auto | Override colors. By default the widget follows the OS color scheme (`prefers-color-scheme`), with dark as the fallback. Explicit values win; partial overrides merge on top of the detected base. | | `title` | `string` | `'Share Feedback'` | Panel header. | | `placeholder` | `string` | `'Tell us what you think... (optional)'` | Comment placeholder. | | `showEmailOption` | `boolean` | `true` | Show the "share my email" checkbox. | | `showScreenshotOption` | `boolean` | `true` | Show the screenshot upload button (up to 3 images, 10MB each). | | `environment` | `string` | undefined | Tag feedback with an environment. Omit for your default environment, see the warning above. | | `metadata` | `Record` | undefined | Arbitrary metadata attached to every submission. | | `baseUrl` | `string` | `'https://usero.io'` | Override the API host (self-hosted Usero). | | `plugins` | `UseroPlugin[]` | undefined | Opt-in plugins, for example [session replay](/docs/widget/session-replay). | | `getUser` | `() => User \| null` | undefined | Vanilla only. Returns the current logged-in user (or null for anonymous). React uses the `user` prop instead. | | `onSubmit` | `(data) => void` | undefined | Fires after a successful submission. | | `onError` | `(err: Error) => void` | undefined | Fires on init or submission error. | | `onOpen` / `onClose` | `() => void` | undefined | Fire when the panel opens or closes. | ## Content Security Policy If your app has a strict CSP, allow `https://usero.io` in `connect-src` (and `https://unpkg.com` in `script-src` if you use the script tag). The widget makes no other cross-origin requests. ## Next - [Session replay](/docs/widget/session-replay): record sessions and link each feedback to the moment it was submitted - [Headless](/docs/widget/headless): building your own UI? Keep the widget's submission pipeline, identity, and plugins, drop ours - [POST /api/feedback](/docs/api/feedback): the raw API the widget submits to --- # Headless (bring your own UI) The [widget](/docs/widget) renders a launcher button and a panel with our copy. If you want full control, a modal that matches your design system, a feedback form embedded in your settings page, your own copy, use the headless core instead. You get the same submission pipeline, identity handling, and plugin support as the widget, with zero UI. `@usero/sdk/headless` is its own subpath export with no widget CSS, no React, and no rrweb: about 3KB gzipped. There is deliberately no `open()`, `close()`, or `isOpen`. When you own the UI, you own its state. ## Install ```bash title=npm npm install @usero/sdk ``` Requires `@usero/sdk` v1.3.0 or later. ## Vanilla quickstart Create one controller at startup and wire `submit` to your own form: ```ts title=Vanilla import { createUseroFeedback } from '@usero/sdk/headless' const usero = createUseroFeedback({ clientId: 'YOUR_CLIENT_ID', environment: import.meta.env.MODE, }) async function handleSubmit(rating: 1 | 2 | 3 | 4, comment: string, files: File[]) { const screenshots = await Promise.all(files.map(file => usero.uploadScreenshot(file))) const result = await usero.submit({ rating, comment, screenshots }) if (!result.success) { showError(result.error) return } showThanks() } ``` `submit()` validates the payload, captures page context (URL, title, referrer) automatically, runs the plugin pipeline, and POSTs to Usero. A submission needs a rating or a non-empty comment; everything else is optional. `uploadScreenshot(file)` uploads one image (10MB max) and resolves with a `ScreenshotData` you include in a later `submit({ screenshots: [...] })`. Unlike `submit`, it rejects with an `Error` on failure, since an upload UI usually wants try/catch per file. ## React ```tsx title=React import { useUseroFeedback } from '@usero/sdk/headless/react' import { useState, type FormEvent } from 'react' function FeedbackModal({ onDone }: { onDone: () => void }) { const usero = useUseroFeedback({ clientId: 'YOUR_CLIENT_ID' }) const [comment, setComment] = useState('') const [error, setError] = useState(null) async function handleSubmit(e: FormEvent) { e.preventDefault() const result = await usero.submit({ comment }) if (!result.success) { setError(result.error ?? 'Something went wrong') return } onDone() } return
{/* your design system here */}
} ``` The hook is SSR-safe (the controller is created in an effect, never on the server), StrictMode-safe, and destroys the controller on unmount. Options are captured on first render, except `user`, which stays reactive: pass the current user object (or `null` on logout) and the SDK re-identifies when it changes by value. `@usero/sdk/headless/react` also re-exports the full headless surface, so one import path covers the hook and the types. ## Error handling `submit()` resolves with `{ success: false, error }` instead of throwing, for validation and network failures alike, so your form can render the message directly. No try/catch needed around submit; reserve that for `uploadScreenshot`. ## Identify your users Declaratively, pass `user` (React) or `getUser` (vanilla) in the options. `getUser` is re-resolved at submit time, so a login that happens after the controller was created is picked up without extra wiring: ```ts title=Vanilla createUseroFeedback({ clientId: 'YOUR_CLIENT_ID', getUser: () => (auth.user ? { id: auth.user.id, email: auth.user.email } : null), }) ``` Or imperatively: ```ts title=Vanilla usero.identify({ id: user.id, email: user.email, traits: { plan: 'pro' } }) usero.identify(null) // logout: rotates the anonymousId ``` Passing `null` is a logout: it rotates the anonymous id so the next visitor's trail does not merge into the previous person. Identify calls are deduped, so calling on every render or route change is free when nothing changed. ## Session replay with your custom UI Pass `sessionReplay()` into `plugins` and every submission from your custom UI deep-links to the exact moment in the recording, the same as it does with our widget. The plugin pipeline runs on every `submit()`, so the replay plugin attaches `sessionReplayId` and `replayOffsetMs` for you. See [Session replay](/docs/widget/session-replay) for recording options and privacy defaults. ```ts title=Vanilla import { createUseroFeedback } from '@usero/sdk/headless' import { sessionReplay } from '@usero/sdk/replay' const usero = createUseroFeedback({ clientId: 'YOUR_CLIENT_ID', plugins: [sessionReplay()], }) ``` > [!WARNING] **Custom UI inside a ShadowRoot?** Call `usero.notifyShadowMount(root)` after attaching it, so the recorder > re-snapshots and captures your UI. Without it, your feedback form is invisible in the replay. Light-DOM UIs are recorded without > any extra call. ## Options | Option | Type | Default | Description | | ------------- | -------------------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | `clientId` | `string` | required | Your Usero client id. See [Find your clientId](/docs/find-your-client-id). | | `apiUrl` | `string` | `'https://usero.io'` | Override the API host (self-hosted Usero). | | `environment` | `string` | undefined | Tag feedback with an environment. Omit for your default environment, same rule as the [widget](/docs/widget). | | `metadata` | `Record` | undefined | Instance-wide metadata attached to every submission. Per-submission metadata passed to `submit()` is deep-merged over it (one level). | | `plugins` | `UseroPlugin[]` | undefined | Same plugin API as the widget, for example [session replay](/docs/widget/session-replay). | | `user` | `UseroUser \| null` | undefined | Declarative identity. In React this is the one reactive option. | | `getUser` | `() => UseroUser \| null \| undefined` | undefined | Callback re-resolved at submit time. Pass at most one of `user` / `getUser`. | ## Controller `createUseroFeedback(options)` and `useUseroFeedback(options)` both return a `UseroFeedbackController`: | Method | Returns | What it does | | ------------------------- | ----------------------------- | ---------------------------------------------------------------------------------------------- | | `submit(payload?)` | `Promise` | Validate, run plugins, POST. Resolves `{ success: false, error }` on failure, never throws. | | `uploadScreenshot(file)` | `Promise` | Upload one image for a later submit. Rejects on failure. | | `identify(user \| null)` | `void` | Imperative identify. `null` logs out and rotates the anonymousId. Deduped. | | `whenReady()` | `Promise` | Resolves once every plugin's `onInit` has settled. Immediate when there are no plugins. | | `notifyShadowMount(root)` | `void` | Tell recording plugins a ShadowRoot hosting your UI was mounted. | | `destroy()` | `void` | Run every plugin's `onDestroy` and inert the controller. Further submits resolve unsuccessful. | ## Exported types Everything is typed. From `@usero/sdk/headless` (and re-exported by `@usero/sdk/headless/react`): - `UseroFeedbackOptions`, `UseroFeedbackController` - `SubmitFeedbackPayload`, `SubmissionResponse`, `ScreenshotData`, `FeedbackSubmission`, `FeedbackRating` - `UseroUser`, `UseroUserTraits`, `UseroUserTraitValue` - `UseroPlugin`, `PluginContext`, `PluginLogger` - Helpers for advanced UIs: `validateFeedbackSubmission` (and its `ValidationResult`) to run the same validation as `submit()` before you POST, and `mergePluginPatches` if you orchestrate plugins yourself. ## Next - [Session replay](/docs/widget/session-replay): record sessions and deep-link each submission into the recording - [POST /api/feedback](/docs/api/feedback): the raw API, if you do not want the SDK at all - [Widget](/docs/widget): the drop-in path when you do not need a custom UI --- # Session replay Record what your users did in the browser and watch it back in the Usero dashboard. As of `@usero/sdk` v1.2.0 you do not need the feedback widget: one import and one `.start()` call records the session, with no UI and nothing added to the DOM. If you also run the widget, each feedback submission deep-links to the exact moment in the recording where the user hit submit. Recording uses rrweb and streams events to Usero as gzipped chunks while the user is on the page, so you capture the whole session rather than only the moments around a feedback submission. ## Install `rrweb` ships inside the replay chunk, so `npm install @usero/sdk` is the only install step. Replay lives in its own subpath export (`@usero/sdk/replay`); consumers who never import it pay zero rrweb bytes. Even after you import it, rrweb lazy-loads at runtime via dynamic `import()` only once a recording starts. ## Standalone (no widget) ```ts title=Vanilla import { sessionReplay } from '@usero/sdk/replay' sessionReplay({ clientId: 'YOUR_CLIENT_ID' }).start() ``` That is the whole integration. If you know who the user is, pass `getUser` so replays show up under the right person: ```ts title=Vanilla const replay = sessionReplay({ clientId: 'YOUR_CLIENT_ID', getUser: () => (auth.user ? { id: auth.user.id, email: auth.user.email } : null), }) replay.start() // Optional: end the recording early. Flushes buffered events and // finalises the session server-side. replay.stop() ``` `getUser` is re-invoked at session start and at every chunk boundary, so a login that happens mid-session is picked up without extra wiring. Returning `null` after a user was identified is treated as a logout. `.start()` is idempotent: calling it while a recording is live anywhere on the page is a no-op. It is also a no-op during server-side rendering, and logs an error if no `clientId` was provided. `.stop()` flushes, finalises, and tears down listeners; calling `.start()` again afterwards begins a new replay session. ## React ```tsx title=React import { useSessionReplay } from '@usero/sdk/replay/react' function App() { useSessionReplay({ clientId: 'YOUR_CLIENT_ID' }) return } ``` The hook is SSR-safe (a no-op on the server) and StrictMode-safe (the dev-mode double effect starts exactly one recording). Recording is page-scoped: it survives the component unmounting on client-side route changes and ends when the page is hidden or closed. The hook returns the replay instance, so you can call `.stop()` to end a recording early. Options are captured on first render; to track a user who logs in mid-session, pass a `getUser` callback rather than changing options. ## With the feedback widget Pass the same factory to the [widget's](/docs/widget) `plugins` array. Each feedback submission then carries a link to the exact moment in the recording where the user hit submit, so you watch the bug instead of asking for reproduction steps. The replay also feeds Usero's [AI-drafted fix PRs](/feature/session-replay): the recording is the context the AI reads before opening a pull request. ```ts title=Vanilla import { initUseroFeedbackWidget } from '@usero/sdk' import { sessionReplay } from '@usero/sdk/replay' initUseroFeedbackWidget({ clientId: 'YOUR_CLIENT_ID', plugins: [ sessionReplay({ // Wait 3s after load before starting. If the user navigates away // first, rrweb is never fetched and no session is created. startAfterMs: 3000, // Record 50% of sessions. Decided once at init. sampleRate: 0.5, }), ], }) ``` ```tsx title=React import { UseroFeedbackWidget } from '@usero/sdk/react' import { sessionReplay } from '@usero/sdk/replay' const plugins = [sessionReplay()] export function App() { return } ``` In plugin mode you do not pass `clientId`, `user`, or `getUser`: the widget's own configuration is authoritative. Building your own feedback UI instead of the widget? The same `plugins: [sessionReplay()]` works with [the headless SDK](/docs/widget/headless), and submissions from your custom UI deep-link into the recording the same way. The legacy import path `@usero/sdk/plugins/session-replay` still works and resolves to the same module. New code should import from `@usero/sdk/replay`. ## One recording per page At most one replay recording runs per page, whichever way it was started: - If a recording was started standalone and the feedback widget mounts later with a `sessionReplay()` plugin, the widget does NOT start a second recorder. It links to the running recording: feedback submissions deep-link into it, and the widget takes over user resolution while mounted. - A widget unmount never kills a recording it merely adopted. Recordings are page-scoped. - `.start()` while any recording is live is a no-op, which is also what makes the React hook StrictMode-safe. ## Options and privacy defaults | Option | Default | What it does | | ------------------ | -------------------------------- | --------------------------------------------------------------------- | | `maskAllInputs` | `true` | Mask `` and `