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:
- Hosted pages — no code. Share a withfeedback URL.
- Embed script — one
<script>tag. Works on any stack. - 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 forscript[data-wf-widget]and de-dupes with adata-wf-bootedmarker.- 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.jsalias, with pinned immutable copies atwidget/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.
- 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. - 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: thewidget/v1/alias receives fixes, a copy never does. An inactive widget renders nothing, and so does a survey widget whose survey is not live. - 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, andidentifyonly prefills them. Escape the values as JavaScript strings — in Django that is theescapejsfilter:
<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:
- 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. - 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.
- When eligible, set a flag for the template. Do not record the ask yet — the page may be closed before the prompt shows.
- 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 setsnps_last_asked_at = now()with one conditionalUPDATE(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>
- Do not use
localStorageas 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.
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
fetchTestimonialsstrips (baking a 10-minute token into cached HTML would hand every visitor the same dead token). Use the client hookuseTestimonialsif 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.
useTestimonialsde-dupes for you;fetchTestimonialshands 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.