# 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<string | null>(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 <form onSubmit={handleSubmit}>{/* your design system here */}</form>
}
```

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<string, unknown>`              | 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<SubmissionResponse>` | Validate, run plugins, POST. Resolves `{ success: false, error }` on failure, never throws.    |
| `uploadScreenshot(file)`  | `Promise<ScreenshotData>`     | 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<void>`               | 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
