Survey Widget

Embed multi-question surveys in your product. Fire them on URL pattern, custom JS event, or time-on-page. Banner, modal, or inline display.

Overview

The Survey widget renders multi-question surveys inside your product — open text, scale, NPS, single/multi choice, boolean. It fires when a URL pattern matches, a custom JS event dispatches, or a time-on-page threshold elapses. Surveys can also be opened by id from your own UI code via SeggwatSurvey.show(survey_id, { force: true }).

The widget pulls live survey definitions from the server on load, so customers paste the snippet once and you author surveys from the dashboard.

Three display modes are supported:

  • Banner — bottom slide-in (default)
  • Modal — centered overlay with backdrop
  • Inline — mounted inside a container you specify

Quick Start

Drop the script anywhere on your page:

html
<script src="https://seggwat.com/static/widgets/v1/seggwat-survey.js"
        data-project-key="your-project-key"></script>

The widget fetches live surveys from GET /api/v1/projects/{project_key}/surveys-config. For a survey to fire automatically, all of these must hold:

  1. The survey is in Live status.
  2. A trigger rule matches (URL pattern, custom event, or time-on-page).
  3. The visitor is past the survey's cooldown window (default 90 days).
  4. The per-survey sample-rate roll passes (default 100%).
  5. The org is on the Pro plan (required to publish the survey Live; Starter can only draft and preview).

Configuration Options

Required Attributes

data-project-keystringrequired

Your unique project identifier from the SeggWat dashboard.

Optional Attributes

data-modestringdefault:"banner"

Display mode. Valid values: banner, modal, inline.

  • banner — fixed bottom slide-in, dismissible
  • modal — centered overlay with backdrop
  • inline — mounted into data-container, no backdrop
html
data-mode="modal"
data-containerstring

CSS selector for the container to mount the widget into. Required when data-mode="inline".

html
data-container="#survey-slot"
data-survey-idstring

When set, only the survey with this id is considered and its triggers are bypassed — the widget shows it as soon as it loads (still subject to cooldown / sample rate). Useful for embedding a specific survey on a static landing page.

data-match-targetstringdefault:"path"

What URL-pattern triggers match against. Single-page apps (React, Vue, Next.js, Astro) that route without full page reloads are re-evaluated automatically on navigation; this controls what the patterns compare to.

  • pathwindow.location.pathname (default; correct for server-rendered and history-routed sites)
  • hash — the hash treated as the route, so a pattern of /pricing matches #/pricing (hash-routed SPAs)
  • fullpathname + search + hash, so patterns can include query strings
html
data-match-target="hash"
data-cooldown-daysnumber

Override the per-survey cooldown for all surveys returned by this embed. 0 disables the cooldown entirely. Intended for dev / QA.

data-global-cooldown-daysnumber

Override for the project-wide fatigue cap. Normally you set this once in the dashboard (Widgets → Surveys → "Don't show another survey for") and it applies to every embed automatically. This attribute is a dev/QA escape hatch that overrides the dashboard value for a single embed.

When in effect, no trigger-driven survey fires within this many days of any survey last being shown — independent of each survey's own cooldown. Forced shows (data-survey-id, SeggwatSurvey.show()) and lifecycle invitations bypass the cap but still reset its timer. 0 or unset disables it.

data-button-colorstringdefault:"#2563eb"

Hex color used for primary buttons, selected states, and progress bar. Hex format only (e.g. #10b981).

data-languagestringdefault:"auto-detect"

Language code for the widget UI chrome (Step labels, Next/Back/Submit/Skip). Supported: en, de, sv, fr. Falls back to English when unsupported. Per-survey copy overrides authored in the dashboard take precedence over these defaults.

data-show-powered-bybooleandefault:"true"

Show or hide the "Powered by SeggWat" footer. Set to false, 0, or no to hide.

data-versionstring

Track responses against specific application versions (e.g. 1.2.3, v2.0.0-beta). Stored on linked Rating / Feedback / SurveyResponse rows.

data-api-urlstringdefault:"auto-detect"

Override the API endpoint. Useful for self-hosted or staging environments.

Triggers (configured per survey)

Each survey has its own trigger configuration. The widget evaluates triggers on every survey returned by the config endpoint and shows the first one that matches (one survey at a time). When more than one survey matches the same URL, the most recently updated survey wins — editing or launching a survey is treated as the "make this take precedence" signal.

A trigger reads as a sentence with three parts:

Show when {the when conditions are met} on pages matching {the where patterns}, except on {the exclude patterns}.

  • Where (url_patterns) — a page gate. The survey can only fire on matching paths. Empty means "any page".
  • When (events, time-on-page, scroll depth, exit intent) — the firing moment. Empty means "as soon as the where gate is satisfied" (page load).
  • Except (exclude_url_patterns) — paths that always suppress the survey.

The where and when groups are AND'd together: the survey fires when a when condition is met while the current path satisfies the where gate. Within each group, a combinator (url_match / when_match) chooses any (OR, the default) or all (AND) — see Combining conditions.

URL path patterns (where)

Glob-style patterns matched against window.location.pathname by default (configurable via data-match-target for hash-routed SPAs or query strings):

  • * matches any sequence within a single path segment
  • ** matches across segments
/pricing
/billing/**
/account/*/settings

url_match controls how multiple patterns combine — any (default) fires on any matching path; all requires every pattern to match. A survey with only a where gate (no when conditions) fires as soon as the user lands on a matching path.

In single-page apps the widget re-evaluates URL patterns on every client-side route change (pushState/replaceState, popstate, hashchange), so a survey targeting /pricing fires even when the user navigates there without a full page reload. The where gate is re-checked at fire time, so a when trigger armed on one page won't fire after the user navigates off the gated path.

Custom event names

Names the widget listens for. Fire them from your own code at meaningful milestones:

javascript
window.SeggwatSurvey.trigger("checkout_complete");
window.SeggwatSurvey.trigger("first_export");

Time-on-page (when)

Show the survey after N seconds of foreground (visible) time on the page — time spent on a backgrounded tab doesn't count.

Scroll depth

Show the survey once the visitor scrolls past a percentage of the page (1100). Useful for content and documentation pages where engagement correlates with scroll.

Exit intent

Show the survey when the pointer leaves the viewport toward the top of the window — the classic abandonment signal. Desktop only: the widget gates on a fine pointer, so it never fires on touch devices.

Exclude URL patterns

A suppression guard (not a "show" condition). Paths matching these glob patterns never fire the survey, even when another trigger condition matches. Takes precedence over every other condition and is re-checked on SPA navigation, so navigating into an excluded path suppresses an already-armed time/scroll/exit/event trigger.

/admin/**
/checkout/confirm

Combining conditions

The where group and the when group are always AND'd together, and exclude_url_patterns always overrides. Two combinators control how conditions combine within a group:

Field Group any (default) all
url_match where fire on any matching path every pattern must match the path
when_match when the first met condition fires the survey every condition must be met (each latches once satisfied)
Show when 10s on page and scrolled 50% on pages matching /pricing/*, except on /admin/**
        └─────────── when (when_match: all) ──────────┘ └─ where ─┘            └ except ┘

Question Kinds

The widget renders any combination of these question shapes:

Kind Input Notes
open_text textarea Honors max_chars and placeholder
nps 0–10 button row Shows "Not likely" / "Very likely" labels
scale min–max button row Custom low_label / high_label shown beneath
single_choice radio list One option from a list
multi_choice checkbox list Multiple, with optional min / max cardinality
boolean two-button toggle Custom true_label / false_label

Choice options can set allow_other_text: true. When the user picks that option, a free-text "Other:" input appears beneath the choices and is submitted as other_text on the answer.

Each question can also be marked required. The Next button stays disabled until the answer is non-empty; non-required questions show a Skip button.

Thank-You Actions

After the last question, the widget shows the server-resolved thank-you action:

  • Message — headline + optional body text
  • DiscountOffer — headline, coupon code (monospace, dashed border), CTA button. The widget stays open longer (12s) before auto-hiding to give the user time to copy the coupon
  • Reactivate — headline + reactivate CTA (typically for win-back flows)
  • Custom — arbitrary HTML (only org admins can author surveys, so this is trusted to the same extent as the survey definition)

Install with an AI assistant

Using Cursor, Claude Code, GitHub Copilot, or another AI coding assistant? Paste this prompt and it will add the widget for you. Replace YOUR_PROJECT_KEY with your project key from the dashboard.

Prompt
Add the SeggWat survey widget to my site. It's a single script tag — no
package to install, no SDK to wire up.

<script src="https://seggwat.com/static/widgets/v1/seggwat-survey.js"
        data-project-key="YOUR_PROJECT_KEY"></script>

Rules:
- Add the tag once in the shared root layout / template (e.g. Next.js root
  layout, Astro base layout, the master <head> or before </body>), NOT
  per-page. The widget pulls live survey definitions and targeting rules from
  the server, so one global include covers the whole site.
- This one tag is the entire install. Don't add any other SeggWat scripts,
  don't npm/yarn install anything, and don't build a framework wrapper for it.
- Leave data-project-key as YOUR_PROJECT_KEY — I'll swap in my real key.
- Which surveys fire, where, and when is authored in the SeggWat dashboard, so
  don't add extra data-* attributes unless I ask.

Examples

html
<script src="https://seggwat.com/static/widgets/v1/seggwat-survey.js"
        data-project-key="your-project-key"></script>
html
<script src="https://seggwat.com/static/widgets/v1/seggwat-survey.js"
        data-project-key="your-project-key"
        data-mode="modal"></script>

Inline inside a specific container

html
<div id="survey-slot"></div>

<script src="https://seggwat.com/static/widgets/v1/seggwat-survey.js"
        data-project-key="your-project-key"
        data-mode="inline"
        data-container="#survey-slot"></script>

Force a specific survey, bypass its triggers

html
<script src="https://seggwat.com/static/widgets/v1/seggwat-survey.js"
        data-project-key="your-project-key"
        data-survey-id="65cf...e9"
        data-mode="inline"
        data-container="#exit-survey"></script>

Dev / QA (bypass cooldown across all surveys)

html
<script src="https://seggwat.com/static/widgets/v1/seggwat-survey.js"
        data-project-key="your-project-key"
        data-cooldown-days="0"></script>

JavaScript API

The widget exposes window.SeggwatSurvey for programmatic control:

Identify the user

javascript
SeggwatSurvey.setUser("user_12345");

User IDs are opaque — max 255 chars, alphanumeric + hyphens + underscores. Never send PII.

Fire a custom event trigger

javascript
SeggwatSurvey.trigger("onboarding_done");

Dispatches a seggwat:survey:<name> event on window. Any survey whose event_names contains that name will be shown (subject to cooldown + sample rate).

Show a specific survey manually

javascript
SeggwatSurvey.show("survey_id");                  // respects cooldown
SeggwatSurvey.show("survey_id", { force: true }); // bypass cooldown (dev only)
SeggwatSurvey.hide();

Inspect state

javascript
SeggwatSurvey.hasAnswered("survey_id"); // boolean — inside cooldown window?
SeggwatSurvey.surveys;                  // read-only snapshot of live surveys

Reset local state

Clears this visitor's per-survey cooldown / sample-roll stored in localStorage. Does not delete server-side data.

javascript
SeggwatSurvey.reset();

Veto a survey before it appears

Right before any survey paints, the widget dispatches a cancelable seggwat:survey:beforeshow event on window. Call preventDefault() to suppress that survey — useful when your own modal or checkout flow is open. This fires on every show path (trigger, manual show(), and lifecycle invitation), so it's a single choke point.

javascript
window.addEventListener("seggwat:survey:beforeshow", (e) => {
  if (document.querySelector(".my-checkout-modal.is-open")) {
    e.preventDefault(); // don't stack a survey on top of checkout
  }
  // e.detail = { survey_id, invitation_token }
});

When a lifecycle-invitation survey is vetoed, the widget moves on to the next pending invitation token.

Accessibility

  • Modal surveys are a real focus trap: focus moves into the dialog on open, Tab/Shift+Tab cycle within it, and focus is restored to the previously focused element on close.
  • Escape dismisses banner and modal surveys (inline surveys have no dismiss affordance). A dismiss without engagement enters the cooldown; dismissing mid-answer does not, so the visitor can resume later.
  • Banner and inline surveys never steal focus, so they don't interrupt keyboard users mid-task.

How It Works

  1. Script loads, auto-detects its base URL, bootstraps seggwat-core.js if not already present.

  2. Fetches the live surveys list from GET /api/v1/projects/{project_key}/surveys-config (sorted most-recently-updated first).

  3. For each survey: if it has no when conditions, fires as soon as the where gate matches the path. Otherwise arms its when triggers (time-on-page visible-time only, scroll-depth, exit-intent, event-name listeners) and fires once they're satisfied per when_match. SPA route changes re-run where matching.

  4. On fire: fires the cancelable beforeshow hook, then checks the global fatigue cap, exclude patterns, the where gate (still matching?), localStorage cooldown, and sticky sample-rate roll. If all pass, displays the survey in the configured mode.

  5. User steps through one question per screen (Prev / Next), with required validation.

  6. On submit, POSTs to /api/v1/surveys/submit with project_key + survey_id + answers.

  7. Server atomically writes: one Rating per measurable answer (NPS → nps, boolean → helpful, 1–N scale where N ≤ 10 → star; scales outside that range stay sidecar-only), one combined Feedback row for all open-text answers (tagged source: Survey), and one SurveyResponse sidecar linking everything.

  8. Widget renders the resolved thank_you action and auto-hides after a short grace.

  9. Script loads, auto-detects its base URL, bootstraps seggwat-core.js if not already present.

  10. Fetches the live surveys list from GET /api/v1/projects/{project_key}/surveys-config.

  11. For each survey: if it has no when conditions, fires as soon as the where gate matches the path. Otherwise arms its when triggers (time-on-page, scroll-depth, exit-intent, event-name listeners) and fires once they're satisfied per when_match.

  12. On fire: checks the where gate, localStorage cooldown + sticky sample-rate roll. If all pass, displays the survey in the configured mode.

  13. User steps through one question per screen (Prev / Next), with required validation.

  14. On submit, POSTs to /api/v1/surveys/submit with project_key + survey_id + answers.

  15. Server atomically writes: one Rating per measurable answer (NPS → nps, boolean → helpful, 1–N scale where N ≤ 10 → star; scales outside that range stay sidecar-only), one combined Feedback row for all open-text answers (tagged source: Survey), and one SurveyResponse sidecar linking everything.

  16. Widget renders the resolved thank_you action and auto-hides after a short grace.

API Endpoints

Submit a response

POST /api/v1/surveys/submit

Payload (client-triggered path):

json
{
  "project_key": "uuid",
  "survey_id": "65cf...e9",
  "answers": [
    { "question_id": "q1", "value": { "type": "nps", "value": 9 } },
    { "question_id": "q2", "value": { "type": "text", "value": "Loved the trial" } }
  ],
  "completed": true,
  "path": "/dashboard",
  "version": "1.2.3",
  "submitted_by": "user_12345",
  "locale": "en"
}

Response (201 Created):

json
{
  "response_id": "...",
  "feedback_id": "...",
  "rating_ids": ["...", "..."],
  "thank_you": {
    "type": "message",
    "headline": "Thanks!",
    "body": "Your response has been recorded."
  }
}

feedback_id is only present when at least one open-text answer was given. thank_you is null if the visitor didn't qualify (e.g. partial submission with show_to: completers).

Fetch the widget config

GET /api/v1/projects/{project_key}/surveys-config

Public, no auth. Returns the live, ClientSide-triggered surveys for the project plus their triggers, cooldown, sample rate, copy overrides, and thank-you actions. Returns an empty list when the project is not on Pro.

Track impressions and dismissals

POST /api/v1/surveys/events

Public, no auth. Batches shown / dismissed events from the widget so the dashboard can show response and dismissal rates next to raw submission counts. The widget fires these automatically — you don't need to call this endpoint yourself.

Payload:

json
{
  "project_key": "uuid",
  "events": [
    { "survey_id": "65cf...e9", "kind": "shown" },
    { "survey_id": "65cf...e9", "kind": "dismissed" }
  ]
}

Response (202 Accepted):

json
{ "accepted": 2 }

The third event kind, submitted, is incremented server-side from POST /surveys/submit. Don't send it from the widget.

Privacy and cookies

The Survey widget is built to keep customer pages free of consent-banner surface area. Concretely:

  • No cookies are set. Per-survey cooldown / sample-rate state is stored in localStorage, which under EU guidance is considered "strictly necessary" for delivering the feature the visitor is interacting with.
  • No tracking identifiers leave the browser. The widget never sends an IP, user agent, fingerprint, or anonymous visitor ID as part of an event. Server logs naturally capture IP for short-lived rate limiting, but that data is never persisted into the analytics counters.
  • Event counters are pure aggregates. Impressions and dismissals are stored only as (project, survey, day, event_kind, count) rows. There is no way to reconstruct who saw which survey from that data, which means the counters are not personal data under the GDPR.
  • submitted_by is opt-in. If you call SeggwatSurvey.setUser("...") or pass data-user-id, that identifier rides along on the submitted response. It does not appear on impression / dismissal events.

If your privacy policy already covers SeggWat for feedback / ratings, you typically do not need to add a separate disclosure for survey event counters — the same "non-identifying aggregate analytics" framing applies.

Data Model

Each submission writes up to three correlated row types:

  • Rating — one row per measurable answer, feeding the existing aggregation pipelines:
    • NPS answer → type: "nps" (0–10 with band stats)
    • Boolean answer → type: "helpful" (true/false)
    • Scale answer with min: 1 and max ≤ 10type: "star" with max_stars = max (e.g. CSAT 1–5, CES 1–7)
    • Scales outside that range (0-based, > 10) are preserved on the sidecar only — no Rating row is created
    • Every Rating carries the same path, version, and submitted_by as the submission so it shows up under the standard stats filters
  • Feedback — a single combined row for all open-text answers in the submission, formatted as **prompt**\nanswer\n\n**prompt**\nanswer. Tagged source: "Survey". Flows through the regular triage queue.
  • SurveyResponse sidecar — links every Rating + Feedback row, denormalizes every answer (including its prompt_snapshot), and carries completed, locale, path, version, submitted_by.

Question identity is preserved via stable string ids on each SurveyQuestion. Reordering, renaming, or rewording questions in the dashboard later doesn't corrupt analytics — historical responses keep referencing the original question_id and carry their own prompt snapshot.

Best Practices

Use Cases

Activation Pulse

Fire SeggwatSurvey.trigger("activation_complete") after the user completes onboarding. A 3-question survey (clarity / value / blockers) captures impressions while they're fresh.

Pricing-Page Intent

URL trigger on /pricing. Ask what they're evaluating and the biggest blocker. Pair with a DiscountOffer thank-you on completion.

Feature Feedback

Custom-event trigger after the user uses a new feature N times. Tight, focused: clarity / usefulness / what's missing.

Pre-Cancel Save

URL trigger on /account/cancel. Open-text "what's not working" + a reactivate CTA in the thank-you. Catches churn intent before it lands.

Troubleshooting

Navigation