Skip to content

Documentation

Feedback, from snippet to report

One script tag on your site. Anyone looking at a page can point at what is wrong, say why, and send you the picture. This page covers every setting and every function the widget has.

Install the widget

  1. 1

    Create a project

    One per site or application. An email address is enough, no card.

  2. 2

    Paste the snippet

    Copy it from the project's Install panel and add it to the layout every page shares.

  3. 3

    Send a test report

    Open the launcher, point at something, send. The report appears in your project.

The snippet in the app already carries your project's key, and the settings you chose there. Paste it once, just before the closing body tag. Written by hand, it looks like this:

<script
  src="https://cdn.feedback.o27.io/widget.js"
  data-project-key="YOUR_PROJECT_KEY"
  defer
></script>

The tag configures itself from its own data-* attributes and loads the latest widget, so updates reach your site without touching the snippet. In a single-page application, load it once in the HTML shell: it stays mounted while your router changes pages.

Without a project key the widget does not appear, and the browser console says why.

If your site sends a Content Security Policy, allow the address in the snippet's src in script-src, and https://feedback.o27.io in connect-src: that is where reports are sent.

How a report is made

What your visitors see, so you can tell them about it.

  • Open. The launcher expands into a panel the reporter can drag anywhere. It doubles as the legend in the image.
  • Point. Choose Point, then click an element to select it, or drag to draw an area. Escape cancels.
  • Comment. Each selection gets a numbered outline on the page and a comment field in the panel. Note adds a message about the whole page, without selecting anything.
  • Frame. The image shows the visible area when every selection fits in it, otherwise the band of the page that holds them all. One click switches to the full page.
  • Copy or send. Copy puts the image on the clipboard; Send saves it to your project with the comments. Preview is optional and shows the exact image first.
  • Minimize or close. Minimize keeps the report and hides the outlines; Close discards it. Clear removes every selection and offers Undo for a few seconds.

Selections belong to the current visit and are not stored. Once a report is sent, Send stays off until it changes, so your team never receives the same one twice.

Options

Set options as attributes on the script tag, or pass them to init() under the name shown beneath each attribute. The project's Install panel writes the common ones for you.

data-project-keyinit() option projectKey · Default none
Your project's embed key. Reports are sent to that project.
data-positioninit() option position · Default bottom-right
Corner the launcher starts in: top-left, top-right, bottom-left or bottom-right.
data-offset-xinit() option offsetX · Default 20
Distance in pixels from the left or right edge the position anchors to.
data-offset-yinit() option offsetY · Default 20
Distance in pixels from the top or bottom edge the position anchors to.
data-compactinit() option compact · Default auto
always shows the icon alone. auto drops the label below 480 pixels.
data-accentinit() option accent · Default #6d5dfc
Colour of the launcher and of the outlines.
data-localeinit() option locale · Default browser
Force the panel's language: en, fr or nl. Otherwise the visitor's own choice, then their browser's.
data-visibilityinit() option visibility · Default protected
protected for an application showing customer data, public for a showcase or documentation site. See What a capture shows.
data-excludeinit() option exclude · Default none
CSS selectors, separated by commas, for data to blur that the page cannot mark itself.
data-consoleinit() option console · Default off
on records log levels and timings with each report, never the messages. See Console diagnostics.
data-tag-<key>init() option tags · Default none
One label per attribute, such as data-tag-environment="staging". See Tags.
data-reporter-email data-reporter-nameinit() option reporter · Default none
Who is sending the report. See Who is reporting.
data-storage-keyinit() option storageKey · Default o27-feedback
Where the launcher's position is saved. Only needed when two widgets share one domain.
data-api-urlinit() option apiUrl · Default https://feedback.o27.io
Where reports are sent. Change it only for a self-hosted platform.
init() onlyinit() option vocabulary · Default none
Your interface's strings in the active language, so they stay readable in captures. See Interface and data.
init() onlyinit() option consoleMessages · Default none
Exact, static log messages whose text may be kept. See Console diagnostics.

Placing the launcher

An application whose header owns the top edge and whose composer owns the bottom has no free corner. Keep the corner and push the launcher clear of them with an offset:

<script
  src="https://cdn.feedback.o27.io/widget.js"
  data-project-key="YOUR_PROJECT_KEY"
  data-position="bottom-right"
  data-offset-y="88"
  defer
></script>

Your values are a starting point, not a lock: a launcher the visitor dragged keeps its own position, and picking a corner in the panel's settings returns to yours.

Who is reporting

Name the person sending the report so your team can follow up. On a page your server renders, write it on the tag:

<script
  src="https://cdn.feedback.o27.io/widget.js"
  data-project-key="YOUR_PROJECT_KEY"
  data-reporter-email="ada@example.com"
  data-reporter-name="Ada Lovelace"
  defer
></script>

In an application that learns who the user is after the page loads, call setReporter once they are known, and with null on sign-out:

function withFeedback(run, tries = 50) {
  const feedback = window.o27?.feedback
  if (feedback) run(feedback)
  else if (tries > 0) setTimeout(() => withFeedback(run, tries - 1), 200)
}

withFeedback((feedback) => feedback.setReporter({ email: user.email, name: user.name }))

// on sign-out
withFeedback((feedback) => feedback.setReporter(null))

Both fields are optional and trimmed. An address that is not one is dropped, and an empty pair clears the reporter.

Not verified. Anyone holding the embed key can send any value, so the reporter is a label, not proof of identity. It appears on the report in Feedback, in the Slack message and in the Linear issue: send only what the people reading them may see.

Tags

Tags are labels every report carries: an environment, a release, a trace identifier. Put fixed ones on the tag, one attribute each:

<script
  src="https://cdn.feedback.o27.io/widget.js"
  data-project-key="YOUR_PROJECT_KEY"
  data-tag-environment="production"
  data-tag-release="2026.10.1"
  defer
></script>

For values that change after the page loads, call setTags. It merges into the current set, and a null value removes a key:

withFeedback((feedback) => feedback.setTags({ trace_id: traceId }))

// and later
withFeedback((feedback) => feedback.setTags({ trace_id: null }))

At most 20 tags per report. Keys use letters, digits and _ . : -; keys and values stay under 200 characters. Anything else is dropped.

The environment tag has a role of its own: each project can send each environment's reports to a different place. See Slack and Linear.

The window.o27.feedback object

The script exposes four functions on window.o27.feedback once it has loaded.

init(options) → { destroy() }
Mounts the widget again with these options merged over the tag's and the previous call's. Use it to pass the options only JavaScript can hold, or to change the accent when your theme changes.
destroy()
Removes the widget from the page. init() brings it back.
setTags(tags)
Merges labels into the set every report carries. A null value removes a key.
setReporter(reporter | null)
Replaces the reporter, unlike setTags, which merges. null clears it.

The script tag is deferred, so the object does not exist while your own code first runs, and a call made too early is dropped without an error. Wait for it:

function withFeedback(run, tries = 50) {
  const feedback = window.o27?.feedback
  if (feedback) run(feedback)
  else if (tries > 0) setTimeout(() => withFeedback(run, tries - 1), 200)
}

withFeedback((feedback) =>
  feedback.init({ vocabulary: () => Object.values(i18n.messages) }),
)

The script carries no type declarations. In TypeScript, declare the global once:

declare global {
  interface Window {
    o27?: {
      feedback?: {
        init(options?: Record<string, unknown>): { destroy(): void }
        destroy(): void
        setTags(tags: Record<string, string | number | boolean | null>): void
        setReporter(reporter: { email?: string; name?: string } | null): void
      }
    }
  }
}
export {}

What a capture shows

Every image the widget produces protects what the reporter did not point at. This applies to Copy, Send and the download offered when a send fails, on every plan, and the reporter cannot turn it off.

An application (data-visibility="protected", the default) blurs every piece of data outside the reporter's selections. In the image sent to your project, that text is first replaced by placeholder text of the same length, so it cannot be recovered from the blur.

A public site (data-visibility="public") blurs nothing unless the page marks it private.

What the reporter selects is always shown as it is. The page itself is never changed: only the image is.

Interface and data

On an application, what counts as interface, and stays readable, is decided in three layers. Each one overrides the one before.

  1. 1Structure. Text inside nav, header, footer, button, label, th, legend, summary and their ARIA roles is interface. Form fields, iframes, images, table cells and every other text run are data, headings included. The nearest ancestor decides, so a field inside a nav is still data.
  2. 2Your strings. Pass the active language's interface strings as vocabulary; a text run equal to one of them stays readable wherever it sits. Pass a function if the catalogue loads later or the language changes: it is read at capture time.
  3. 3Your markers. data-feedback="private" blurs an element and everything inside it; data-feedback="public" keeps it readable. The nearest marked ancestor wins, so a public toolbar can hold a private counter. Markers work on both kinds of site.
<header>Acme <span data-feedback="private">Jean Dupont</span></header>
<section data-feedback="private">Customer account details</section>
<nav data-feedback="public">…</nav>

<script
  src="https://cdn.feedback.o27.io/widget.js"
  data-project-key="YOUR_PROJECT_KEY"
  data-exclude=".customer-details, #billing-summary"
  defer
></script>

No access to the markup? List selectors in data-exclude; they are treated as private. An invalid selector blocks the capture rather than letting data through.

Console diagnostics

Off by default. With data-console="on", each report carries the level and timing of the last 60 console calls. Their content, error messages, stacks and objects are replaced with [redacted].

To keep the text of specific messages, approve them through init(). Only a call with one string argument matching an entry exactly keeps its text:

withFeedback((feedback) =>
  feedback.init({
    console: true,
    consoleMessages: ['Checkout failed', 'Search unavailable'],
  }),
)

Approve only static messages that name no person: adding an argument or interpolating an identifier redacts the whole message again.

What a report contains

It contains the protected image of the chosen frame, the comments the reporter wrote, where each selection sits, the page's origin, the viewport size, and browser facts: user agent, language, pixel ratio, screen size, appearance preferences and time zone. Plus your tags, the reporter, and console diagnostics when you turned them on.

It does not contain the page title, the URL's path, query or fragment, or any CSS class or id.

Checking your integration

  • Put recognisable test content inside a protected area, choose Copy and look at the image: data outside your selections must be unreadable, controls and selections sharp.
  • Repeat with filled-in form fields and with the selectors you listed in data-exclude.
  • In your browser's network tools, open the capture request: no title, the URL's origin only, and no console field unless you turned diagnostics on.

Slack and Linear

Reports always land in your Feedback project. Connect Slack or Linear once, in Integrations, and each project can also send them on. Integrations are part of the Team plan.

Slack posts each report to a channel, image included. Invite the Feedback app to that channel, or the post is refused.

Linear turns a report into an issue in a team, with its image and comments. Pick the status, project and labels new issues start with.

Destinations are set per project and per environment, read from the environment tag, so staging reports can go to one channel and production ones to another. Each destination is automatic, receiving every new report, or on send, receiving a report when someone sends it from its page in Feedback.

Troubleshooting

The launcher does not appear

Check that the tag carries your project's key, or uses the project address copied from the app. The browser console says when a key is missing. On a site with a Content Security Policy, see Install the widget.

The launcher covers one of my own buttons

Add data-offset-x or data-offset-y, or pick another corner. See Placing the launcher.

Reports arrive without the reporter

setReporter was probably called before the script loaded, and dropped. Wait for window.o27.feedback, as shown in The window.o27.feedback object.

Parts of my interface are blurred

Text that is not in a structural element counts as data. Mark the area data-feedback="public", or pass your interface strings as vocabulary.

Customer data is readable in captures

Check that the tag does not say data-visibility="public". Then mark the area data-feedback="private", or list it in data-exclude.

The capture fails straight away

An invalid selector in data-exclude blocks every capture. Check the list in the project's advanced options.

An image, a font or a video looks wrong in the capture

See Known limitations.

Known limitations

  • Images served from another domain without CORS, some web fonts, and the content of iframes, canvases and WebGL may not appear exactly as on screen.
  • Selections and comments live for the current visit only. The launcher's position is the one thing kept.