Skip to content

Integrate withfeedback

How to add withfeedback to your own website or app — by hand, or by pointing an AI coding agent at it. This is the whole embed story in one place: the mental model, a recipe per stack, and which surface to drive when a widget needs a survey created first.

There is an agent-readable copy of this page

Point an AI coding agent (Claude Code, Cursor, …) at https://withfeedback.com/integrate.md. It is plain Markdown, linked from llms.txt, so an agent finds it by itself and can integrate withfeedback without anyone reading the dashboard.

The mental model

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

Noun What it is
Project A container for one website or app. It carries a public key (pk_ + 28 hex chars). The key identifies, never authorizes — it is safe to ship in client HTML.
Widget A thing you put on a page. Display widgets show approved 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.
Moderation The gate. Publication needs two stored facts — an approval and display consent — on an active project.

Publication needs approval and consent, separately

A display widget shows a submission only when it is approved and the person gave display consent. Two different facts, stored separately. Neither alone is enough, and a consent withdrawal removes the item from every embed and deletes any video.

Three ways to integrate, from least to most code:

  1. Hosted pages — no code. Share a withfeedback URL.
  2. Embed script — one <script> tag. Works on any stack.
  3. React SDK — @withfeedback/react, when SSR and native DOM styling matter.

And underneath all of them, two API planes: a public plane (unauthenticated, browser-facing) that the script and the SDK call, and an authenticated plane (REST + MCP + CLI) you use to build the integration.

The three jobs

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

Show a wall (grid) or carousel (one at a time) of submissions you have already approved. No survey needed.

By default a display widget shows any approved, consented submission — not only items typed testimonial. Set the widget's filter to kind = testimonial to restrict it.

A collect_feedback widget: a launcher and a panel with text, a star rating, name/email/title/company, a consent checkbox, and an optional video recorder.

The embeddable collect widget always records kind = feedback (which still shows on a wall once approved). To capture items typed specifically as testimonial, use a hosted collection page with kind testimonial, or post to the API with kind: "testimonial".

An nps_popover or a collect_survey widget. Survey types are NPS, CSAT, CES, PMF (or custom). Both widgets need a linked survey — create it first.

Read results with get_nps_summary (MCP) or the survey results endpoint: score, promoter/passive/detractor counts, a histogram and a 30-day trend.

Widget types

Config is validated per type when you save; unknown keys are rejected.

Type Family What it does Needs a survey? Video?
wall display Grid of approved, consented submissions no —
carousel display One-at-a-time slider, optional autoplay no —
collect_feedback collect Text + rating + person + consent, optional video (records feedback) no allowed
collect_survey collect A survey's questions in a panel yes no
nps_popover collect An NPS survey as a pop-over yes no

grid and marquee are not real types yet

They appear only as a "later phases" note in the code. Creating one is rejected. Treat them as possible future types, not shipping ones.

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 the snippet filled in), or from the get_widget_embed_code MCP tool / the widget embed-code REST endpoint. Its shape is fixed:

<script async
  src="https://withfeedback.fra1.cdn.digitaloceanspaces.com/widget/v1/widget.js"
  data-wf-key="YOUR_PUBLIC_KEY"
  data-wf-widget="YOUR_WIDGET_ID"></script>
  • async — the bundle boots whether it loads sync or async. It scans for script[data-wf-widget] and de-dupes with a data-wf-booted marker.
  • Attributes: data-wf-widget (required), data-wf-key (required), data-wf-api (optional API-base override), data-wf-preview (dashboard preview only — suppresses the impression beacon and all writes).
  • Isolation — the widget mounts in a Shadow DOM and never uses innerHTML. It cannot inherit or leak your page's CSS.
  • The bundle is one vanilla-TypeScript file with no runtime dependencies, served from the CDN and held under a 30 KB gzip budget by CI. A mutable widget/v1/widget.js alias, with pinned immutable copies at widget/v<x.y.z>/widget.js.

Prefill and open the collect panel from your own page through the window.withfeedback queue — you push command tuples, there are no methods:

window.withfeedback = window.withfeedback || [];
window.withfeedback.push(["identify", { name: "Ada", email: "ada@example.com" }]);
window.withfeedback.push(["open"]); // or ["open", "<widget-id-or-type>"]

Per stack

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.

Paste the tag into the layout, or the one template that needs it. No package, no build step.

Drop the tag into the component or page markup, or inject it once on mount. No framework binding is needed — the bundle boots itself.

For React/Next, prefer the SDK below — the script tag renders only in the browser, which is the wrong tool for server-rendered testimonials.

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:
<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:
<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>
  1. 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 is a real package — components, hooks and server-side rendering — for the one thing the script tag cannot do: put testimonials in the served HTML, where crawlers and AI readers (which mostly do not run JavaScript) can see them, with no first-paint jump.

npm install @withfeedback/react

React 18.2 or 19, no runtime dependencies, no CSS import. It has three entry points so a page that only shows testimonials never bundles a camera, a form and an upload:

Import Runs where For
@withfeedback/react/server Server Component, getStaticProps, a route handler, a build script Testimonials in the served HTML
@withfeedback/react The browser Pagination, the carousel
@withfeedback/react/collect The browser Feedback, NPS, survey forms

Server-rendered display is the recommended path:

// 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_ID" });
  return <TestimonialWall payload={payload} />;
}

Collecting (all three collect types) renders into normal DOM, styled like your product:

"use client";
import { CollectWidget } from "@withfeedback/react/collect";

<CollectWidget payload={payload} publicKey="YOUR_PUBLIC_KEY" />;

Three behaviours worth knowing:

  • A purely server-rendered wall records no impressions. The beacon needs a short-lived view token, which fetchTestimonials strips (baking a 10-minute token into cached HTML would hand every visitor the same dead token). Use the client hook useTestimonials if you need impressions.
  • The consent checkbox label is the consent record — it changes when a video is attached, and a tick already given is cleared when the wording changes.
  • Pagination de-duplicates by id. useTestimonials de-dupes for you; fetchTestimonials hands you the raw page, so de-dupe yourself if you page by hand.

See the fuller React SDK page for props and hooks.

Hosted pages (no code)

Nothing to embed — create the page in withfeedback and share its URL. Identified by the public key, never authorized by it.

URL Purpose
/p/<public_key>/<slug>/ Collection form (Job B). Kind decides testimonial vs feedback.
/s/<public_key>/<slug>/ Survey page (Job C). ?score=8 prefills NPS from an email.
/p/<public_key>/wall/ A hosted "wall of love" (Job A).
/consent/withdraw/<token>/ A person withdraws consent.

A hosted page can be put in an <iframe> only from origins you list in the project's embed domains; the default is frame-ancestors 'self' and it is never opened to everyone.

On the Team plan these pages can be served on your own hostname — see Custom domain.

Public API

Base https://withfeedback.com/api/public/v1/ — no auth, cookie-free, CORS enabled. Per-IP throttling and an IP blocklist apply everywhere; the two text write endpoints (submissions and survey responses) also add a honeypot field, a minimum-fill timer 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 beacon, or the video-completion call.

Method + path Job Notes
GET /widgets/<uuid>/ A Config + one page of approved items + a fresh view token. ?page= clamped to 1–10.
POST /submissions/ B Testimonial or feedback; also the final step of the video flow.
POST /survey-responses/ C Answers; widget_id resolves the survey.
POST /events/ A Impression beacon {view_token}. 202 for any readable request.
POST /uploads/videos/ + …/<asset_id>/complete/ B Steps 1–3 of the four-step video flow.

Send the impression beacon with fetch, not sendBeacon

For cross-origin custom code use fetch(url, { method: "POST", keepalive: true, credentials: "omit" }). navigator.sendBeacon always sends credentialed and is refused by this cookie-free, CORS-open plane.

Video is four steps, not three: reserve → upload → complete → then POST /submissions/ with the returned asset_id and upload_token. See Video.

Building it: which surface to drive

To create the survey and widget the recipes reference, use any of three surfaces. They are not fully interchangeable — only REST can create a project today; survey and widget creation exist on all three.

Surface Use it when Read
MCP server The agent has the withfeedback MCP connector MCP, Hosted MCP
CLI (withfeedback) The agent has only a shell, or it is CI CLI
REST You are calling from your own backend Authentication → Scopes

A typical agent-driven setup for an in-app NPS survey:

The tool parameters are survey_type / widget_type, and name is required:

flowchart LR
    A["create_survey(name, survey_type=nps)"] --> B["update_survey(status=live)"]
    B --> C["create_widget(name, widget_type=nps_popover, survey_id)"]
    C --> D[get_widget_embed_code]
    D --> E[paste tag / CollectWidget]

Scopes: widget writes need write:widgets, survey writes need write:surveys. A client connected with an older token must re-consent to gain them.

What is shipped

Shipped: the embed script, the React SDK, the CLI and MCP packages on PyPI, the hosted remote MCP, hosted pages, the full public API, and video testimonials.

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