STAGING
Skip to content

Documentation

Spotted, 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-accent data-accent-darkinit() option accent · Default #6d5dfc
Colour of the launcher and of the outlines. data-accent-dark (or { light, dark } in JavaScript) sets another one for the dark theme, #9d8fff by default.
data-themeinit() option theme · Default light
light or dark panel, or auto to follow the visitor's system setting. Switching keeps the report in progress.
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-launcherinit() option launcher · Default on
off hides the launcher, for a site that opens the widget from its own interface. See From your own interface.
data-point-on-openinit() option pointOnOpen · Default off
on opens the panel with pointing armed, so the first click selects an element. Otherwise it opens on the general note. Ignored on touch screens.
data-storage-keyinit() option storageKey · Default o27-spotted
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 withSpotted(run, tries = 50) {
  const spotted = window.o27?.spotted
  if (spotted) run(spotted)
  else if (tries > 0) setTimeout(() => withSpotted(run, tries - 1), 200)
}

withSpotted((spotted) => spotted.setReporter({ email: user.email, name: user.name }))

// on sign-out
withSpotted((spotted) => spotted.setReporter(null))

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

Without an address from your page, the widget asks on Send whether the reporter wants to follow their spotted. Declining sends it anonymously and the question comes back next time. Accepting takes an email and an optional name, remembered in that browser so the question is not asked again, and editable in the widget's settings. A report sent with that address is followed up exactly like one where your page named 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 Spotted, 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:

withSpotted((spotted) => spotted.setTags({ trace_id: traceId }))

// and later
withSpotted((spotted) => spotted.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.

From your own interface

A site that already has a “Report a problem” entry can open the widget from it, hide the launcher where it does not belong, and hear how the report ended:

withSpotted((spotted) => {
  spotted.setLauncher(false)
  reportButton.addEventListener('click', () => spotted.open())
  spotted.on('sent', () => showThanks())
  spotted.on('abandoned', () => reportButton.focus())
})

setLauncher(false) hides the launcher without unloading anything: what the reporter had written is still there on the next open(). To hide it from the first paint, put data-launcher="off" on the tag.

sent fires once a report lands; abandoned when the reporter closes the widget on a report they never sent. Minimizing is neither: the report is kept. on returns a function that removes the listener.

The window.o27.spotted object

The script exposes seven functions on window.o27.spotted 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.
open()
Opens the widget with whatever the reporter had already written. Called before the widget has appeared, it opens as soon as it does.
setLauncher(visible)
Shows or hides the launcher. Hiding it keeps the widget and its report.
on('sent' | 'abandoned', listener) → off()
Calls the listener when a report is sent or abandoned. Returns a function that removes 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 withSpotted(run, tries = 50) {
  const spotted = window.o27?.spotted
  if (spotted) run(spotted)
  else if (tries > 0) setTimeout(() => withSpotted(run, tries - 1), 200)
}

withSpotted((spotted) =>
  spotted.init({ vocabulary: () => Object.values(i18n.messages) }),
)

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

declare global {
  interface Window {
    o27?: {
      spotted?: {
        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
        open(): void
        setLauncher(visible: boolean): void
        on(event: 'sent' | 'abandoned', listener: () => void): () => 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-spotted="private" blurs an element and everything inside it; data-spotted="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-spotted="private">Jean Dupont</span></header>
<section data-spotted="private">Customer account details</section>
<nav data-spotted="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:

withSpotted((spotted) =>
  spotted.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 Spotted 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 Spotted 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. When the issue moves in Linear, the report's status follows within minutes; each status's category decides which, and you can adjust that per team.

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 Spotted.

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.spotted, as shown in The window.o27.spotted object.

Parts of my interface are blurred

Text that is not in a structural element counts as data. Mark the area data-spotted="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-spotted="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.