Documentation

SupportShot installs with a script tag. This page covers installing it, wiring it into a JavaScript framework, attaching the logged-in user, controlling the widget from your own UI, and exactly what data leaves your visitors' browsers.

Install

Create a project in your SupportShot dashboard, copy its public key (it looks like ss_pk_…), and paste this into your HTML. Put it just before the closing </body> tag on every page you want covered — a shared layout, template, or footer include is usually the right place.

HTML
<!-- paste just before </body> -->
<script>
  window.SupportShot = window.SupportShot || {
    q: [],
    identify: function (user) { window.SupportShot.q.push(["identify", user]); },
    open: function () { window.SupportShot.q.push(["open"]); },
    close: function () { window.SupportShot.q.push(["close"]); }
  };
</script>
<script
  src="https://supportshot.com/widget.js"
  data-project="ss_pk_your_project_key"
  async
></script>

The first block is the queue stub: a few hundred bytes that let you call SupportShot.identify(), open(), and close() immediately, before the widget file has finished downloading. Calls land in window.SupportShot.q; the widget takes over the global on load and replays everything queued, in order. If you never call the API you can drop the stub and keep only the second tag, but leaving it in costs nothing.

The widget tag is asynchronous, so it never blocks rendering. On load, the widget asks SupportShot for your project’s configuration (enabled state, button color, position, prompt text) and mounts a floating button inside a shadow root attached to document.documentElement. Your CSS cannot affect the widget and the widget’s CSS cannot leak into your page. (It attaches to the root element rather than to <body> on purpose: a transform, filter or perspective on body — which plenty of page transitions and modal libraries set — makes it the containing block for fixed descendants, and the button would scroll away with the page.)

Checking it worked

Reload your site and look for the report button in the bottom-right corner. If it is not there, open the browser console:

  • No SupportShot messages at all — the tag is not on the page, or a content blocker stopped it. Confirm the <script> element exists in the rendered HTML.
  • A single console.warn from SupportShot — the widget disabled itself on purpose, and the message says why: the config request for that key was refused (a mistyped data-project, or a key from another project), the project is switched off in the dashboard, the tag is missing its data-project or its src, or the script was loaded in a way that hides its own tag from it (as a module, an eval, or a bundled import rather than a plain <script src>).
  • The allowed-origins list is not a reason the button fails to appear. It is enforced when a report is submitted, not when the widget loads — so an origin you have not listed gets a widget that mounts and draws normally, and a submission that comes back 403. If reports are being refused rather than missing, that list is the first place to look.

The widget never throws into your page. It will not break your site, and it will not retry in a loop. What it does not do is give up quietly: every path that stops short of drawing the button writes exactly one console.warn naming the reason. One line, once, and then nothing — which is why “no SupportShot messages at all” above means something different from a warning you can read.

React, Next.js, and other frameworks

The plain script tag works in every framework. In the Next.js App Router, load it with next/script so Next controls when it is injected:

Next.js (App Router)
// app/layout.tsx
import Script from "next/script";

const SUPPORTSHOT_QUEUE = `
  window.SupportShot = window.SupportShot || {
    q: [],
    identify: function (user) { window.SupportShot.q.push(["identify", user]); },
    open: function () { window.SupportShot.q.push(["open"]); },
    close: function () { window.SupportShot.q.push(["close"]); }
  };
`;

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        {children}
        <Script id="supportshot-queue" strategy="beforeInteractive">
          {SUPPORTSHOT_QUEUE}
        </Script>
        <Script
          src="https://supportshot.com/widget.js"
          data-project="ss_pk_your_project_key"
          strategy="afterInteractive"
        />
      </body>
    </html>
  );
}

The queue stub goes in with strategy="beforeInteractive" so it exists by the time your components hydrate and start calling identify(). In the Pages Router the two tags live in different files: Next only honors beforeInteractive inside pages/_document.tsx, so the queue stub goes there, and the widget <Script> (afterInteractive) goes in pages/_app.tsx. In a Vite or Create React App project, paste the plain HTML version into index.html. In Rails, Django, Laravel, or WordPress, put it in the shared layout or footer template. There is no npm package to install and no bundler configuration — the widget is a single self-contained file with no runtime dependencies.

Identify your users

If your visitors are logged in, tell SupportShot who they are. identify() attaches the identity to every ticket that visitor submits afterwards and pre-fills (and hides) the email field, so the form is one textarea instead of two.

JavaScript
SupportShot.identify({
  id: "user_8241",              // your internal id (optional)
  email: "priya@example.com",   // pre-fills the email field (optional)
  name: "Priya Raman",          // shown on the ticket (optional)
});

Every field is optional; pass what you have. Call it as soon as you know who the user is: with the queue stub from the install snippet in place, a call made before the widget has loaded is held and replayed, so you never need to wait for a load event. (Without the stub, window.SupportShot does not exist until the widget arrives — guard the call or keep the stub.) Call identify() again after a user switches accounts to replace the stored identity.

In React, do it in an effect so it re-runs when the user changes:

React
"use client";
import { useEffect } from "react";

export function SupportShotIdentity({
  user,
}: {
  user: { id: string; email: string; name: string } | null;
}) {
  useEffect(() => {
    if (!user) return;
    window.SupportShot?.identify({
      id: user.id,
      email: user.email,
      name: user.name,
    });
  }, [user]);

  return null;
}

Open and close the widget yourself

SupportShot.open() opens the report panel and SupportShot.close() closes it. Use them to add your own entry points — a “Report a problem” item in your help menu, a link in an error page, a button inside an empty state:

HTML
<button type="button" id="report-a-problem">
  Report a problem
</button>
JavaScript
// in your own script file
document
  .getElementById("report-a-problem")
  .addEventListener("click", function () {
    SupportShot.open();
  });

Bind the click in your own script file rather than with an inline onclick attribute: under a Content Security Policy, inline event handlers need 'unsafe-hashes' even when the code is hashed, and the policy below deliberately does not include it.

Both calls work at any time once the widget has loaded, and both go through the same queue stub as identify(), so an open() fired during page load opens the panel as soon as the widget is ready. A panel opened from your own button behaves exactly as it does from the floating one.

TypeScript

The widget adds one global. Drop this declaration into your project to get types for it:

TypeScript
// types/supportshot.d.ts
interface SupportShotApi {
  identify(user: { id?: string; email?: string; name?: string }): void;
  open(): void;
  close(): void;
  /** Present only on the queue stub, before widget.js takes over. */
  q?: unknown[];
}

declare global {
  interface Window {
    SupportShot?: SupportShotApi;
  }
}

export {};

Project keys and allowed origins

The ss_pk_ key is a public identifier. It ships in your HTML, anyone can read it, and that is fine — it grants nothing except the ability to submit a report. What protects you is the allowed-origins list on the project: SupportShot checks the Origin header of every submission against it. The list has three states, and it is worth knowing which one you are in.

  • Empty — how every new project starts. Nothing is rejected: reports are accepted from wherever the widget runs and marked unverified in your inbox, so you can paste the tag into a staging site and watch a real ticket arrive before you have settled on your domains. Treat it as a setup state, not a resting place; fill the list in as soon as you know your origins.
  • Configured — the state to be in. List every origin the widget runs on, including staging: https://example.com, https://www.example.com, https://staging.example.com. Matching is exact — scheme, host, and port — and any origin that is not on the list is rejected. Tickets from origins you listed arrive without the unverified flag.
  • * — allows every origin on purpose. It is accepted, but it means anyone who copies your key can post tickets to your project, so use it only while testing.
  • Submissions are rate limited to 10 per minute per project, which stops a stuck script or a bored visitor from flooding your inbox.
  • If a key does leak somewhere you do not want, regenerate it in the project settings and update your tag. The old key stops working immediately.

Content Security Policy

If your site sends a CSP header, allow SupportShot to load and to submit:

Response header
Content-Security-Policy:
  script-src 'self' https://supportshot.com 'sha256-B0b2m/Rq039fHL71unY5jkuMGEEGWDaxEBFuWZhi8RU=';
  connect-src 'self' https://supportshot.com;
  img-src 'self' data: blob:;
  • script-src https://supportshot.com — loads widget.js.
  • connect-src https://supportshot.com — fetches your project config and posts the report.
  • connect-src also has to cover the hosts your own fonts and images come from — a CDN, an image host, a font service. Taking the screenshot re-reads those assets from the page, and a connect-src that omits their origin blocks the read: the report still sends, but the screenshot comes back with blank rectangles where those images were and the wrong typeface where those fonts were. Nothing errors; the picture is just missing pieces. Most sites already list these origins because their own code fetches from them — it is the site whose assets are only ever referenced in markup and CSS that gets caught.
  • img-src data: blob: — the screenshot is rendered and previewed in the visitor’s own browser using data and blob URLs. Without this the report still sends, but the preview and annotation step cannot draw.
  • No style-src change is needed. The widget styles itself with a constructed stylesheet adopted into its shadow root, which is CSSOM rather than markup — no style-src directive applies to it. Under style-src 'self' the widget is fully styled, and we test it that way. Only a browser too old for constructed stylesheets falls back to an inline <style> element, which such a policy does block — and there the cost is appearance and nothing else. The floating button also pins its own geometry inline, through the CSSOM that no style-src directive governs, so even with every stylesheet blocked it stays a small circle in the corner rather than a full-width block of chrome dropped across your page. The panel and the annotation surface are not pinned that way: unstyled, they render as plain stacked form controls. Legible, usable, and nothing like the finished thing.
  • 'sha256-B0b2m/Rq039fHL71unY5jkuMGEEGWDaxEBFuWZhi8RU=' — the hash of the inline queue stub, the first <script> block in the install snippet at the top of this page. A policy without it blocks the stub, and SupportShot.identify() then throws a ReferenceError. The hash covers the bytes between <script> and </script> exactly as they appear in that snippet, newlines and indentation included, so retyping or reindenting invalidates it. Copy the snippet verbatim, or recompute your own version:
  1. Save the stub to a file — everything between <script> and </script>, starting with the newline after the opening tag and ending with the newline before the closing one, and nothing else. Our snippet is exactly 275 bytes; check yours with wc -c stub.js. Most editors add a trailing newline when they save, and that extra byte changes the digest.
  2. Hash it: openssl dgst -sha256 -binary stub.js | openssl base64 -A
  3. Prefix the output with sha256-, wrap the whole thing in single quotes, and add it to script-src — the result looks exactly like 'sha256-B0b2m/Rq039fHL71unY5jkuMGEEGWDaxEBFuWZhi8RU='.

If you would rather not allow any inline script, skip the stub and keep only the widget tag, then guard your calls with window.SupportShot?.identify(…). Without the stub there is nothing to hold early calls, so anything you send before widget.js has loaded is dropped rather than queued. Make the call somewhere it can run late — from a load listener on the widget script tag, or from an effect that runs after that load event has fired.

Screenshots are never uploaded anywhere but SupportShot, so no other host needs to be allow-listed.

What the widget collects

Nothing is collected until a visitor clicks the button and presses send. Before that the widget only keeps two small in-memory ring buffers so the context is there if a report happens. When a report is submitted, the ticket contains:

Filling those buffers means the widget wraps three things on the page as soon as it loads: console’s logging methods, fetch, and XMLHttpRequest. Each wrapper records the entry and calls the original, so behaviour is unchanged and nothing leaves the browser at that point — and if the widget disables itself for any reason (a disabled project, a refused config request, an error of its own) it puts all three back exactly as it found them.

  • The message the visitor typed, and their email address (typed, or supplied by identify()).
  • The screenshot, if they added one: a rendering of the visible viewport at the moment of capture. If they annotated it, both the annotated and the original image are stored.
  • Console entries — the last 200, in one buffer shared between log, info, warn, error and debug calls, uncaught errors, and unhandled promise rejections. Two hundred entries in total, not two hundred of each: a page throwing exceptions pushes its own earlier logging out. Each entry’s message is truncated to 1 kB, and where the browser provides a stack trace it is kept alongside and truncated to 1 kB of its own — so a single entry carries at most about 2 kB.
  • Failed network requests — the last 50 requests that returned status 400 or higher or failed outright: method, URL (truncated at 256 characters), status, duration, timestamp.
  • Page and environment — page URL and title, browser and version, operating system, viewport size, device pixel ratio, timezone, locale, and the widget version.

What it never collects

  • Request or response bodies and headers. Only the method, URL, status, and timing of a failed request are recorded, so tokens in an Authorization header or personal data in a JSON payload never leave the browser.
  • Keystrokes, form values, cookies, or storage. There is no session recording, no input capture, and no click trail.
  • Successful requests. Only failures are recorded.
  • Anything from other pages or tabs. The buffers are per-page and cleared on navigation.

One thing to be deliberate about: a screenshot is a picture of the visitor’s screen, so whatever is visible — their name, their order, their balance — is in the image, and the URL may carry query parameters. The visitor chooses whether to attach one, and can send the report without it. Keep that in mind before enabling the widget on pages that display other people’s data, and tell your visitors about the widget in your own privacy policy.

The widget sets no cookies and writes nothing to localStorage or sessionStorage on your visitors’ devices.

Data retention

Tickets, screenshots, and diagnostics are stored for as long as you keep them. We do not expire them on a timer, because a bug you have not fixed yet is still worth reading six months later.

  • Deleting a ticket removes the row and both screenshot files from storage and invalidates the image links in emails that were already sent.
  • To close your account, email support@supportshot.com from the address you signed up with. Your projects, tickets, and screenshots are deleted from our systems within 30 days.
  • Notification emails sit in your recipients’ mailboxes and are outside our control — deleting a ticket removes the inline image, not the email.

The full picture, including subprocessors, is in the privacy policy.

Troubleshooting

The screenshot is blank or missing pieces

Screenshots are rendered from the page in the browser, so images served from another domain without Access-Control-Allow-Origin may come out blank. Serving those assets with permissive CORS headers, or from your own domain, fixes it. If capture fails completely the widget says so and offers to send the report without a screenshot — a report with no image still beats no report.

The visitor got a success message but nobody received an email

The ticket is safe: the dashboard is the system of record and email is only the notification. Open the ticket and read its Delivery panel — it lists every recipient the report was queued for, whether the send is pending, sent or failed, the reason for a failure, and when a pending one is next due. A send gets three attempts in total, the retries two and four minutes apart; after the third it is marked failed and the reason is kept. If the panel says everything was sent, check the recipients’ spam folders.

An empty panel means one of two things, and which one depends on the ticket’s age. On a recent ticket it means the project had no recipients when the report arrived — add them in project settings; the ticket itself is not lost, and future reports will be delivered. On a ticket more than 30 days old it means nothing at all: a delivery row that has finished — sent or failed — is deleted once it is thirty days old, so an old ticket that was delivered perfectly shows the same empty panel as one that was never sent anywhere. The ticket and its screenshot stay; only the delivery record is swept. (A row still pending is never deleted at any age.)

Browser support

The widget runs on current versions of Chrome, Edge, Firefox, and Safari, on desktop and mobile. Screenshot capture needs a browser that can render the page to a canvas or SVG image; where that is unavailable or fails, the widget drops the screenshot step and still submits the message, diagnostics, and page context.

Still stuck?

Email support@supportshot.com with your project name and, if you have one, the ticket reference (SS-XXXXXX). A person reads it.