Feedback contract
A small dialog people can open on any page of your site or product to tell you what is broken or what they miss. Paste one script tag, or call the endpoint from a dialog of your own design. What arrives lands in one block in the panel next to the uninstall survey and Rate Us, and the owner gets an inbox notice.
1. Paste the widget
One deferred script tag, no inline code, so it survives a strict CSP:
<script src="https://extops.dev/widget.js"
data-extops-widget="wk_XXXXXXXXXXXXXXXXXXXXXXXXXXXX" defer></script>
Allow our origin in script-src and connect-src. The file is
self-hosted, has no dependencies and stays under 7 KB gzip; its styles go through the
CSSOM inside a shadow root, so you do not need 'unsafe-inline' and your
page's CSS cannot reshape the dialog.
Optional attributes: data-locale, data-api,
data-position="left", and data-button="false" when you want to
open it yourself with window.ExtOpsFeedback.open(). Also:
data-button-style="icon" for a round 48px icon button,
data-surface="app" to lift it above an app's bottom navigation on phones, and
data-color-preset to set the button colour from the page.
Light and dark theme
Every colour preset is drawn in a light and a dark theme. By default
(data-theme="auto") the widget follows your page: <html data-theme>,
a dark or light class on <html>, or its
color-scheme. A page without its own theme gets the visitor's OS theme. When your
theme toggle changes one of these, the widget repaints right away, with no reload.
To pin the theme, set data-theme="light" or data-theme="dark". If your
page keeps its theme somewhere the widget cannot see, call
window.ExtOpsFeedback.setTheme('dark') from your toggle, and
setTheme('auto') to go back to following the page.
2. Or call the endpoint yourself
For a site that allows no third-party script at all, or a product that wants the form inside its own design system. Same endpoint our widget posts to, so you never have to choose between your UI and our pipeline.
await fetch('https://extops.dev/api/v1/feedback/widget', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
widget_key: 'wk_XXXXXXXXXXXXXXXXXXXXXXXXXXXX',
topic: 'bug',
comment: userText,
page: location.href,
locale: navigator.language,
event_id: crypto.randomUUID(),
dwell_ms: Date.now() - openedAt,
}),
});
Read the topics the owner configured from
GET /api/v1/feedback/widget/config?key=… so your dialog offers exactly what
the panel offers. Reuse one event_id across retries: the server dedupes on
it, so a flaky network cannot turn one message into two.
3. The widget key is public on purpose
It is printed in your page source, and that is fine: its only power is to create one
feedback row. It cannot write install or uninstall events, cannot read anything beyond
the dialog configuration, and cannot name a project (project_id in the body
is not accepted). That is why a widget gets its own key instead of your source's
webhook_token, which can write metric events and must never appear in HTML.
You can reissue the key at any time; the old one stops being accepted immediately.
4. What we store, and what we do not
We store the topic, the text, the normalized page, the locale, an optional contact, the time, the kind of feedback and which widget collected it.
- Not the query string or fragment of the page. They are dropped on intake: they routinely carry access tokens, e-mail addresses and search terms we never asked for and do not want to keep for 180 days.
- Not the sender's country or IP. A resolved country lives only in the daily aggregate, never on an individual event; the IP is a transient rate-limiter key.
- Not a contact when the widget does not ask for one. An outdated snippet that still sends it gets no error: the field is dropped, because the owner's current configuration is what the privacy text promises.
Retention is 180 days, the same rule as every other kind of feedback.
5. Answers and error codes
A success looks like {"status":"ok","recorded":true}.
recorded: false is also a success: the submission was a
duplicate, or an anti-bot heuristic fired. Show the same thank-you either way, because a
script must not learn which rule caught it and a person retrying after a timeout must not
see an error for a message we already hold.
| HTTP | code | When |
|---|---|---|
| 400 | validation_error | no key, no topic, a topic the widget does not offer, an empty comment, a malformed body |
| 403 | origin_not_allowed | the page's origin is not on the widget's allowlist |
| 404 | not_found | unknown key or a disabled widget: the same answer, so a public key cannot probe which projects exist |
| 429 | rate_limited | 60/hour per key, 10/hour per IP; carries Retry-After |
| 401 | source_token_invalid | server-side path only: wrong source token |
| 500 | internal_error | our fault |
6. Topics, kinds and statuses
A topic is an id plus a label. The id is a slug, because it is
stored on every row and becomes the filter value in the panel; the label is the owner's
own words. A widget with no configured topics offers the defaults: bug,
idea, question, other.
The dialog's colour is one of six presets shared with the hosted pages:
amber (the default), teal, indigo,
violet, rose, slate. One shared list on purpose: a
widget and the hosted pages of the same owner should look alike, and a second palette
would drift from the first.
Stored rows carry a kind: uninstall, rate_us or
widget. The kind is a TAG, not a separate pipeline: the panel block, the
notifications and the retention are shared. The owner's triage status is
new, read or spam; marking spam takes a row out of
the working list and out of notifications without deleting what a person wrote, and it can
be put back.
7. Abuse protection lives on the server
Because the key is public, a browser-side check would be advice rather than a rule. The
server carries it: the widget's origin allowlist (empty means any origin, since a customer
may serve one site from a dozen subdomains), two independent rate limits (per key and per
IP), a honeypot field and a minimum dwell time, and deduplication by
(widget_id, event_id) in the database rather than a pre-check,
because two concurrent retries would both pass a SELECT.
Both limiters fail open: if our Redis is down, a person's feedback still reaches you. For an endpoint that writes one paragraph, that is the lesser evil. We do not add an external captcha, which would break your CSP and your visitors' privacy.
8. The machine-readable contract
The formal description of both intake endpoints, the config read, the enums and the error codes is the OpenAPI 3.1 file. It carries the same contract version (1.0.0).