# Integrate withfeedback

> How to add withfeedback to your own website or app — by hand, or by pointing
> an AI coding agent at this page. withfeedback collects text and video
> testimonials plus NPS/CSAT/CES/PMF survey answers, moderates them with consent
> tracking, and publishes approved ones as embeddable social-proof widgets. The
> whole loop is drivable over a REST API, a CLI and an MCP server.

Human version of this guide: https://docs.withfeedback.com/integrate/. This copy
is plain Markdown so an agent can read it without running JavaScript.

## The mental model (read first)

Four nouns explain the whole product. An integration is always some combination
of them.

- **Project** — a container for one website or app. It carries a **`public_key`**
  (`pk_` followed by 28 hex characters). The public key identifies the project on
  the **write** calls that carry it — submitting a testimonial, a feedback note
  or a survey answer, and reserving a video. It **identifies, never authorizes**:
  it is safe to ship in client HTML. (Widget reads are addressed by the widget
  UUID alone; the impression beacon and video-completion calls use short-lived
  tokens, not the public key.) The rotatable secret is server-side only.
- **Widget** — a thing you put on a page. Two families: **display** widgets show
  approved, consented submissions; **collect** widgets gather feedback or survey
  answers. Addressed by a widget UUID.
- **Submission** — one piece of content: a testimonial, a feedback note, or a
  survey response. Most things a visitor sends become a Submission — but a
  spam-trap hit (a filled honeypot or a too-fast form) gets a success-looking
  response and creates nothing, and a video starts as a `draft` until it is
  finalized.
- **Moderation** — the gate. Publication requires **two** stored facts: an
  approval decision (`status = approved`) and a display-consent flag
  (`consent_display = true`), on an active project. Display widgets render **only**
  submissions with both. A submission missing either never appears.

**Three ways to integrate, from least to most code:**

1. **Hosted pages** — no code. Share a withfeedback URL (a collection form, a
   survey, a wall).
2. **Embed script** — one `<script>` tag. Works on any stack.
3. **React SDK** — `@withfeedback/react`, for React/Next.js, when
   server-rendering and native DOM styling matter.

And, underneath all of them, **two API planes:**

- **Public plane** (`/api/public/v1/…`) — unauthenticated, browser-facing,
  cookie-free. Read a widget's approved items; submit a testimonial, feedback or
  survey answer. This is what the embed script and the React SDK call.
- **Authenticated plane** (`/api/v1/…`, plus the MCP server and the
  `withfeedback` CLI) — JWT/OAuth, team-scoped. Used to **build** the
  integration: create the survey and the widget. (Creating the **project** itself
  is REST-only today.)

## The three functional jobs

Pick the job, then the stack. Every stack below implements these same three.

### Job A — Publish approved testimonials (display)

Show a wall or a carousel of submissions you have already approved.

- Widgets: **`wall`** (grid) or **`carousel`** (one at a time, optional autoplay).
  Display-only, no survey needed.
- Content: approved, display-consented submissions. By default this includes any
  approved feedback, not only items typed as `testimonial`; set the widget's
  **`filter.kind = "testimonial"`** to restrict it.
- Delivery: embed script, React `<TestimonialWall>` / `<TestimonialCarousel>`,
  the hosted wall page `https://withfeedback.com/p/<public_key>/wall/`, or read the JSON yourself
  from `GET /api/public/v1/widgets/<id>/` and render on any backend.

### Job B — Ask for a testimonial or feedback (collect)

Let a visitor leave a written (or video) note you can later publish.

- Widget: **`collect_feedback`** — a launcher plus a panel with text, star
  rating, name/email/title/company, a consent checkbox, and an **optional video
  recorder** (`collect_video`, allowed on this widget type only).
- **Which `kind` you get.** The embeddable `collect_feedback` widget — script or
  React — always records submissions as **`kind = "feedback"`**. That is fine for
  Job A: approved, consented feedback shows on a wall/carousel (unless you
  filtered to `testimonial`). To capture items typed specifically as
  **`testimonial`**, use a **hosted collection page** whose kind is `testimonial`,
  or `POST /api/public/v1/submissions/` with `kind: "testimonial"` from your own
  form.
- **Video** is a four-step flow: reserve -> upload -> complete -> then
  `POST /submissions/` with the returned `asset_id` and `upload_token` to finish
  the submission.
- A genuine completed submission lands **pending** and needs approval before Job
  A can show it. (A honeypot or too-fast submit returns a success-looking 201 but
  creates nothing.)

### Job C — Run an in-app survey: NPS / CSAT / CES / PMF

Ask a satisfaction question inside your product and read the results.

- Survey types: **`nps`, `csat`, `ces`, `pmf`** (or `custom`). Question types:
  `score` (default 0-10; CSAT uses 1-5), `text`, `likert`, `select`. Surveys are
  **versioned and immutable** — editing questions publishes a new version so past
  answers keep their meaning.
- Widgets: **`nps_popover`** or **`collect_survey`**. **Both require a linked
  survey** — create the survey first.
- Delivery: embed script, React `<CollectWidget>`, the hosted survey page
  `https://withfeedback.com/s/<public_key>/<slug>/` (supports `?score=8` prefill for NPS from an
  email), or `POST /api/public/v1/survey-responses/`.
- Read results: `get_nps_summary` (MCP) / `GET …/surveys/<id>/results/` (API) —
  score, promoter/passive/detractor counts, histogram, 30-day trend.

## Widget types (reference)

Config is validated per-type on save; unknown keys are rejected.

| Type | Family | What it does | Needs a survey? | `collect_video`? |
|---|---|---|---|---|
| `wall` | display | Grid of approved, consented submissions | no | — |
| `carousel` | display | One-at-a-time slider, optional autoplay | no | — |
| `collect_feedback` | collect | Panel: text + rating + person + consent, optional video (records `kind=feedback`) | no | allowed |
| `collect_survey` | collect | Panel rendering a survey's questions | **yes** | no |
| `nps_popover` | collect | Pop-over rendering an NPS survey | **yes** | no |

`grid` and `marquee` are **not** valid widget types today — treat them as
possible future types, not shipping ones. Creating one is rejected.

Config keys per type: **wall** — `columns` (1-4), `accent_color` (hex),
`show_rating` / `show_avatars` / `show_dates` (bool), `max_items` (1-100);
**carousel** — `autoplay`, `interval_ms` (2000-30000), `accent_color`,
`show_rating` / `show_avatars`, `max_items` (1-50); **collect widgets** —
`accent_color`, `position` (`bottom-right` | `bottom-left`), `launcher_text`
(<=40 chars). Display widgets also take a `filter` of `{kind, min_rating, tags}`.

## The embed script (works on every stack)

Get the exact tag from the dashboard (the widget page there also offers a
ready-made **prompt for a coding agent**, with this widget's snippet filled in),
or from `get_widget_embed_code` (MCP) /
`GET …/widgets/<id>/embed-code/` (API). Its shape is fixed:

```html
<script async
  src="https://withfeedback.fra1.cdn.digitaloceanspaces.com/widget/v1/widget.js"
  data-wf-key="pk_your_public_key"
  data-wf-widget="your-widget-uuid"></script>
```

- **`async`.** The bundle works whether it loads sync or async (it scans for
  `script[data-wf-widget]` and de-dupes with a `data-wf-booted` marker).
- **Attributes it reads:** `data-wf-widget` (required), `data-wf-key` (required),
  `data-wf-api` (optional API-base override — the script origin serves only static
  JS, the API is a separate origin), `data-wf-preview` (dashboard preview only;
  suppresses the impression beacon and all writes).
- **Isolation.** The widget mounts in a **Shadow DOM** with `:host { all:
  initial }` and writes only via `textContent` / `setAttribute` — never
  `innerHTML`. It does not inherit or leak your page's CSS.
- **The bundle.** One vanilla-TypeScript file, **no runtime dependencies**, served
  from the CDN as `widget/v1/widget.js` (a mutable alias; pinned immutable copies
  live at `widget/v<x.y.z>/widget.js`). It is a small bundle, tracked by CI
  against a **30 KB gzip ceiling**.
- **Optional host-page API** (`window.withfeedback`): a **queue** you push command
  tuples onto — not methods. Prefill the collect panel with
  `window.withfeedback.push(["identify", { name, email }])`; open it with
  `window.withfeedback.push(["open"])` (optionally
  `["open", "<widget-id-or-type>"]` to target one). There is no `.identify(...)` /
  `.open(...)` method form.

The embed is the right choice for Django, Rails, PHP, WordPress, plain HTML,
Vue, Svelte, Astro — any page where you drop a tag. For React/Next, prefer the
SDK.

### Per-stack drop-in

- **Plain HTML / PHP / WordPress:** paste the tag where the widget should appear.
  In WordPress use a Custom HTML block (or add it to the theme). The tag inserts
  the widget immediately after itself.
- **Rails / Django / any server template:** paste the tag into the layout or the
  one template that needs it. No package, no build step.
- **Vue / Svelte / Astro:** drop the tag into the component/page markup, or inject
  it once on mount. There is no framework binding needed — the bundle boots itself.
- **A bare CDN drop-in / SPA:** add the `<script>` once; for a collect widget,
  drive it with the `window.withfeedback` queue described above.

### Server-rendered apps — the full recipe (Django shown)

The per-stack line above is enough for a display widget. A **collect** widget on
a signed-in app needs four more decisions, and they are the same in Django,
Rails, Laravel or any server template — only the escaping helper changes. This
recipe is for the script tag: the React `CollectWidget` renders the same panel
but has **no identify or open API today**, so on React keep the script tag for a
collect widget you want to prefill or trigger.

1. **Which template.** A display widget (`wall`, `carousel`) goes in the one page
   template where it should appear. A collect widget (`collect_feedback`,
   `collect_survey`, `nps_popover`) goes in the base template that **only
   signed-in app pages** extend — not marketing pages, not login/signup, not the
   admin. The launcher button renders on every page that carries the tag, and
   there is no option to hide it.
2. **Keep the tag as issued.** Keep `async`, load the bundle from the CDN URL in
   the snippet, and do not download or vendor the file: the `widget/v1/` alias
   receives fixes, a copy never does. An inactive widget renders nothing, and so
   does a survey widget whose survey is not live.
3. **Identify the signed-in user.** The survey panel (`nps_popover`,
   `collect_survey`) has **no name or email fields**, so without this every
   answer is anonymous. The feedback panel has the fields, and `identify` only
   prefills them. Escape the values as JavaScript strings — in Django
   that is the `escapejs` filter:

   ```django
   <script>
     window.withfeedback = window.withfeedback || [];
     {% if request.user.is_authenticated %}
     window.withfeedback.push(["identify", {
       name: "{{ request.user.get_full_name|escapejs }}",
       email: "{{ request.user.email|escapejs }}"
     }]);
     {% endif %}
   </script>
   <script async src="https://withfeedback.fra1.cdn.digitaloceanspaces.com/widget/v1/widget.js"
     data-wf-key="pk_your_public_key" data-wf-widget="your-widget-uuid"></script>
   ```

   Calls pushed before the bundle loads are queued and replayed, so the order of
   the two tags does not matter. `identify` is visitor-supplied data,
   not a verified identity claim.
4. **CSP.** If the app sends a `Content-Security-Policy` header, the widget
   needs:
   - the CDN origin from the snippet's `src` in `script-src`. Under a nonce
     plus `'strict-dynamic'` policy the allowlist is ignored, so put the nonce
     on the widget `<script>` tag itself; the two inline blocks on this page
     (identify above, the delayed open below) need it too, or read the user
     values from data attributes in an external file;
   - `https://withfeedback.com` in `connect-src`;
   - inline styles in `style-src`: the bundle injects its stylesheet as a
     `<style>` element in the shadow root and sets `style` attributes, so
     `style-src` needs `'unsafe-inline'`. A nonce or hash in
     `style-src` (or a stricter `style-src-elem` / `style-src-attr`) makes
     browsers ignore `'unsafe-inline'`, so a policy that forbids inline styles
     cannot run the script-tag widget — use the React package with `unstyled`
     there;
   - for display widgets, the origins of the avatar and video URLs in the
     widget payload in `img-src` and `media-src` (media comes from the media
     storage, which is not necessarily the script's CDN);
   - for video collection, the storage origin returned by the reserve call in
     `connect-src`, and `blob:` in `media-src` for the recording preview.

   Roll the policy out as `Content-Security-Policy-Report-Only` first and read
   the reports.

The public key in the snippet is safe in HTML. Never put an API token or the
project's identify secret in a template. You do not need to proxy the API: the
widget calls it directly, cookie-free.

### In-app NPS: when to ask

The `nps_popover` and `collect_survey` widgets **do not decide when to ask**,
whatever survey type they link — NPS, CSAT, CES, PMF or custom; the widget type
does not pin the survey type. Each renders a launcher button in a corner
of the page (`position`), and on a customer's page the panel opens only when the
visitor clicks it or when the page pushes
`window.withfeedback.push(["open", "your-widget-uuid"])`. The argument may also
be a prefix of the widget type — `"nps"` matches `nps_popover` but not
`collect_survey` — so prefer the id. Only the dashboard preview opens
itself. There is no timer, no frequency cap and no memory of who was
asked in the widget: that policy belongs in your app, where the user record is.
A pattern that works:

1. Store `nps_last_asked_at` (a nullable timestamp) on the user or profile —
   for a survey that is not NPS, name the field for that survey, one per
   survey you trigger.
2. Decide eligibility **server-side, in one place** (a context processor, a
   middleware or a view mixin), as a read only — for example an account at least
   14 days old, and never asked or last asked more than 90 days ago. Quarterly is
   the common NPS cadence; a CSAT or CES survey is usually asked right after the
   event it measures, so there the trigger is the event, not a timer. Pick your
   own numbers.
3. When eligible, set a flag for the template. **Do not record the ask yet** —
   the page may be closed before the prompt shows.
4. In the template, when the flag is set, wait a few seconds, check that the
   widget really rendered a launcher, then **record the ask and open**, in that
   order. The check: the bundle mounts a `<div data-withfeedback="<widget
   id>">` host with an **open** shadow root, and the launcher button appears
   inside it only after the widget payload loaded — so a blocked, inactive or
   not-live widget shows no button and must not spend the ask. The
   record: POST to a small endpoint of your app that sets `nps_last_asked_at =
   now()` with one conditional `UPDATE` (only where it is still null or older
   than the window, so two tabs racing produce one ask), answers 200 when it
   updated a row and 409 when it did not; open only on a 200:

   ```html
   <script>
     window.withfeedback = window.withfeedback || [];
     setTimeout(function () {
       var host = document.querySelector('[data-withfeedback="your-widget-uuid"]');
       if (!host || !host.shadowRoot || !host.shadowRoot.querySelector("button")) return;
       fetch("/account/nps-asked/", {
         method: "POST",
         headers: { "X-CSRFToken": "{{ csrf_token }}" },
       }).then(function (response) {
         if (response.status === 200) window.withfeedback.push(["open", "your-widget-uuid"]);
       });
     }, 5000);
   </script>
   ```

5. Do not use `localStorage` as the only record: it is per browser and gets
   cleared. The server-side timestamp is the source of truth.

Between asks the launcher stays available for anyone who wants to answer.

**Verify the integration:** sign in and confirm the launcher renders inside a
shadow root and is absent on signed-out pages; submit a test answer and confirm
it arrived — the response count in the survey's results goes up (the results
pages show aggregates, not respondents; `get_nps_summary` over MCP reads NPS
surveys only) and the respondent appears in Contacts with the identified email,
unless that email already belongs to a verified contact, in which case the
answer stays unattached and keeps the claimed email; test the ask
policy (no ask before the minimum account age, one ask, no second ask inside
the window).

## Next.js / React — `@withfeedback/react`

Published on npm (MIT, zero runtime deps, React 18.2 or 19 as a peer dependency,
no CSS import required):

```bash
npm install @withfeedback/react
```

**Why the package and not the script tag** — the script renders in the browser
into a Shadow DOM, which is right for a site withfeedback knows nothing about, but
wrong for two things: **machine readers** (search + AI crawlers mostly do not run
JS, so JS-injected testimonials are near-invisible) and **first paint** (an empty
box, then a jump). The package fixes both, but **only through the `/server` entry
point.**

Three entry points:

| Import | Runs where | Use it for |
|---|---|---|
| `@withfeedback/react/server` | RSC, `getStaticProps`, a route handler, a build script | **Testimonials in the served HTML** |
| `@withfeedback/react` | the browser (a Client Component) | Pagination, the carousel |
| `@withfeedback/react/collect` | the browser | Collecting feedback, NPS, survey answers |

They are split so a page that only shows testimonials never bundles a camera, a
form and an upload.

### Job A (display), server-rendered — the recommended path

```tsx
// app/page.tsx — a React Server Component
import { fetchTestimonials, TestimonialWall } from "@withfeedback/react/server";

export default async function Page() {
  const payload = await fetchTestimonials({ widgetId: "your-widget-uuid" });
  return <TestimonialWall payload={payload} />;
}
```

- `TestimonialWall` and `TestimonialCard` are **pure and hook-free**, so they
  render in an RSC or in the browser. `TestimonialCarousel` holds slide state, so
  it is a **Client Component** and is not in `/server` — but a Server Component can
  fetch the payload and pass it down, and every slide still prerenders.
- Styling: every part carries a stable `wf-` class (`wf-card`, `wf-stars`,
  `wf-text`, `wf-person`, …); `unstyled` drops the inline defaults so you supply
  all CSS.

### Job A (display), client pagination

```tsx
"use client";
import { useTestimonials } from "@withfeedback/react";
const { items, loading, error, hasMore, loadMore } =
  useTestimonials({ widgetId: "your-widget-uuid" });
```

Server-then-client (no double fetch): pass the server payload as `initialPayload`
to `useTestimonials` — it renders immediately and hydration matches.

### Jobs B & C (collect / survey / NPS)

```tsx
"use client";
import { CollectWidget } from "@withfeedback/react/collect";
<CollectWidget payload={payload} publicKey="pk_your_public_key" />
```

`CollectWidget` draws the launcher and panel for **all three** collect types —
which one you get comes from the payload. **It renders into normal DOM, not a
Shadow DOM** (you own this page and want it styled like your product). The video
recorder, honeypot, minimum-fill timer, consent wording and upload flow are
**shared with the script bundle**, so both behave identically.

### Three behaviours to know

1. **A purely server-rendered wall records no impressions.** The impression
   beacon needs a `view_token`; `fetchTestimonials` **strips** it, because shipping
   a 10-minute token into cached HTML would hand every visitor the same dead token.
   If you need impressions, use the client hook.
2. **The consent checkbox label IS the consent record** — it changes when a video
   is attached, and a tick already given is cleared with a note when the wording
   changes.
3. **Pagination de-duplicates by id.** The API pages by offset; approving a new
   testimonial between requests shifts every row. `useTestimonials` de-dupes;
   `fetchTestimonials` hands you the raw page, so de-dupe yourself if you page by
   hand.

The SDK's TypeScript types are generated from the server's OpenAPI schema and a
test fails on drift, so the committed types stay in sync with the schema in the
repo. That is a build-time guarantee against the current schema, not a promise
that an already-published npm version matches a later production deploy — pin a
version you have tested against.

## Hosted pages — the no-code path

Nothing to embed: create the page in withfeedback and share its URL. Identified
by `public_key`, never authorized by it.

| URL | Purpose |
|---|---|
| `https://withfeedback.com/p/<public_key>/<slug>/` | Hosted collection form (Job B): text, rating, person, consent, optional video. Kind decides testimonial vs feedback. |
| `https://withfeedback.com/s/<public_key>/<slug>/` | Hosted survey page (Job C): renders the survey's current version; `?score=8` prefills NPS; `?r=<token>` ties an email recipient. |
| `https://withfeedback.com/p/<public_key>/wall/` | Hosted "wall of love" (Job A): up to 60 publicly-visible testimonials. |
| `https://withfeedback.com/consent/withdraw/<token>/` | A person withdraws consent; the testimonial is unpublished and any video deleted. |

**Framing:** a hosted page can be `<iframe>`-embedded only from origins listed in
the project's embed domains; the default is `frame-ancestors 'self'`, and it is
never opened to `*`.

**Your own domain:** on the Team plan a project's hosted pages can be served on a
custom hostname such as `reviews.acme.com` (the URLs above keep working and
redirect there).

## Public API — call it from your own front end / backend

Base: `https://withfeedback.com/api/public/v1/`. **No auth**, cookie-free, CORS-enabled. Every
endpoint shares **per-IP throttling** and an **IP blocklist**; the two text write
endpoints (**submissions** and **survey responses**) also add a **honeypot**
field, a **minimum-fill timer**, a body cap and per-project caps. The public key
is required on the calls that carry it — a submission, a survey response, a video
reservation — but not on the widget read, the event beacon, or the
video-completion call (those are addressed by the widget UUID or a short-lived
token).

| Method + path | Job | Notes |
|---|---|---|
| `GET /widgets/<uuid>/` | A | Widget config + branding + one page of approved items + a fresh `view_token`. Addressed by the widget UUID (no public key). `?page=` is **clamped** to 1-10. Page size <=30, smaller if the widget's `max_items` is smaller. `Cache-Control: no-store`. |
| `POST /submissions/` | B | Submit a testimonial or feedback. Body: `public_key`, `kind`, `text` (<=10k), `rating` 1-5, `consent_display`, person fields, `widget_id`, honeypot `website`, `form_started_at`. Also the final step of the video flow (send `asset_id` + `upload_token`). |
| `POST /survey-responses/` | C | Answers from a collect widget. Body: `public_key`, `widget_id` (resolves the survey — a bare survey id is deliberately not accepted), optional `version_id`, `answers` (<=20). |
| `POST /events/` | A | Impression beacon: `{view_token}`. Returns **202 for any readable request** (an unknown or expired token included); an unparseable body is a 400 and throttling is a 429. Uses only the `view_token`, no public key. |
| `POST /uploads/videos/` + `…/<asset_id>/complete/` | B | **Steps 1-3 of 4** for a video: reserve -> direct upload -> complete. Reserve creates a `draft` submission and returns a presigned upload + an `upload_token`; complete only verifies the object and starts processing. **Step 4 is `POST /submissions/`** with `asset_id` + `upload_token`. Needs a `widget_id` (feedback widget with `collect_video`) or a `page_slug` (collection page with video). |

**The beacon rule.** For **cross-origin** custom code, send the impression event
with `fetch(url, { method: "POST", keepalive: true, credentials: "omit" })` —
**not `navigator.sendBeacon`**, which always sends credentialed and is refused by
this cookie-free, CORS-open plane.

**CAPTCHA.** A project's captcha setting is off by default and inert without keys.
It only *gates* the video-reserve endpoint on the **hosted-page** path; on the
widget path and elsewhere the captcha result is a recorded **signal**, not a gate.

## Building the integration (authenticated plane)

To create the survey and widget the sections above reference, use any of three
surfaces. They are **not** fully interchangeable: only REST can create a
**project** (the MCP has `list_projects` but no `create_project`, and the CLI has
no projects command); survey and widget creation exist on all three. All three go
through the same server, so they share validation and behaviour. The packaged
`withfeedback-mcp` and the CLI are **REST clients** (they call `/api/v1/…`); the
hosted MCP at `mcp.withfeedback.com` calls the same domain services the REST views
call directly.

- **MCP server** (`withfeedback-mcp` on PyPI, or the hosted remote MCP at
  `mcp.withfeedback.com`): `create_survey`, `update_survey`,
  `publish_survey_version`, `create_widget`, `update_widget`, `delete_widget`,
  `get_widget_embed_code`, plus read/moderation tools. This is the surface an AI
  agent uses.
- **CLI** (`withfeedback` on PyPI): `surveys create/update/publish-version`,
  `widgets create/update/delete`, `testimonials …`, `import csv`.
- **REST** (`/api/v1/teams/<t>/projects/<p>/…`, JWT via `auth/token/`):
  `surveys/`, `surveys/<id>/versions/`, `widgets/`, `widgets/<id>/embed-code/`,
  `collection-pages/`, `submissions/` + moderation.

**Which surface should an agent drive?** An agent with the withfeedback MCP
connector uses the MCP tools. An agent with only a shell uses the CLI, or curls
the REST API.

A typical agent-driven setup for Job C (in-app NPS) — note the tool parameters
are `survey_type` / `widget_type`, and `name` is required:
`create_survey(name="Product NPS", survey_type="nps")` ->
`update_survey(status="live")` ->
`create_widget(name="NPS", widget_type="nps_popover", survey_id=…, is_active=true)`
-> `get_widget_embed_code` -> paste the tag (or drop `<CollectWidget>` in React).

Scopes: widget writes need `write:widgets`; survey writes need `write:surveys`. A
previously-connected client with an older token must re-consent to gain these.

## Cross-cutting facts

- **The public key is safe to expose.** It identifies, never authorizes; it ships
  in the collect embeds and is sent on write calls.
- **Publication needs approval + consent.** A collected item is pending until a
  human approves it; display widgets show only `approved` **and** `consent_display`
  items. Neither flag alone is enough.
- **Caching / freshness.** The public widget payload is cached and keyed on the
  project's content version. Writes through the widget/survey services bump the
  version in the same transaction, so an approval or an edit invalidates the cache
  automatically. Make changes through the API/MCP/CLI, not by poking the database.
- **CSP.** The embed loads JS from the CDN origin and `fetch`es the API origin.
  Under a strict `script-src`, the React package is easier — it bundles its code,
  so no runtime CDN fetch and no inline script. Note it does apply inline `style`
  attributes by default, so under a strict `style-src` pass `unstyled` and ship
  your own stylesheet. Hosted pages set their own `frame-ancestors`.
- **SSR matters for display** (crawler/AI visibility, no layout jump) — the reason
  `@withfeedback/react/server` exists. The collect widget's interactive parts
  (camera, form, upload) run only in the browser, though its launcher prerenders.

## Status — what is shipped

Shipped: the vanilla embed script, the React SDK (`@withfeedback/react`), the CLI
and MCP packages on PyPI, the hosted remote MCP, hosted collection/survey/wall
pages, the full public API, and video testimonials (in-browser recorder plus the
reserve -> upload -> complete -> submit flow).

Not yet shipped — do not rely on: server-signed host-page identity
(`identify_token`); the `grid` and `marquee` widget types; localized / RTL
widgets; regional pricing; AI translation of testimonials.
