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.
<!-- 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 whether to show the “Powered by SupportShot” line) 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.warnfrom SupportShot — the widget disabled itself on purpose, and the message says why: the config request for that key was refused (a mistypeddata-project, or a key from another project), the project is switched off in the dashboard, the tag is missing itsdata-projector itssrc, or the script was loaded in a way that hides its own tag from it (as a module, aneval, 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:
// 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.
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 anything. (Without the stub, window.SupportShot does not exist until the widget has fetched its configuration — wait for the supportshot:ready event or keep the stub.) Call identify() again after a user switches accounts to replace the stored identity.
Say when nobody is signed in, too. Pass an empty object — SupportShot.identify({}) — when the session ends. That is what tells the widget the person it was told about has gone, and it is the call that drops their console buffer, their failed-request list and anything half-typed in the panel before the next visitor uses the same tab. The widget deliberately does not treat the first identify() of a page load as a handover — that is your auth library resolving, not a second person arriving, and throwing away the diagnostics there would empty the log on exactly the reports that need it — so a sign-out that says nothing is a sign-out the widget cannot see. Re-identifying the same person, with more fields filled in as a profile query comes back, is not a handover either and costs nothing.
In React, do it in an effect so it re-runs when the user changes — including when they become null:
"use client";
import { useEffect } from "react";
export function SupportShotIdentity({
user,
}: {
user: { id: string; email: string; name: string } | null;
}) {
useEffect(() => {
// Signed out. Say so: identify({}) is what tells the widget the previous
// reporter is gone, so their console log and half-written report are
// dropped before the next person uses this tab.
if (!user) {
window.SupportShot?.identify({});
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:
<button type="button" id="report-a-problem">
Report a problem
</button>// 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:
// 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. The allowed-origins list on the project is what narrows that: 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.
One honest limit on that list, before the three states. Origin is set by the browser, and only a browser is bound by it. Anything that is not a browser — one curl with -H ‘Origin: https://yoursite.com’ and a key scraped from your page — sends whatever origin it likes and is accepted on the same terms as your own site. So the list stops another website from quietly posting into your project through a visitor’s browser, and it does not stop somebody who has your key from posting deliberately. Treat it as a filter that keeps your inbox yours, not as authentication; the two rate limits below are what bound the damage either way.
- Empty — how every new project starts. Nothing is rejected: reports are accepted from wherever the widget runs and marked unverified — on the row in your inbox and again on the ticket itself, beside the origin the report was posted from — 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. One thing to know before you leave it there: a report accepted under*is recorded verified, exactly as one whose origin matched a domain you listed. What we store is a yes or no, not which of the two it was, so while the list is a wildcard the badge cannot tell a report that passed a check from one that passed none. An empty list is the more honest way to run unrestricted — it accepts the same reports and marks them unverified.- Submissions are rate limited to 10 per minute per project, which stops a stuck script or a bored visitor from flooding your inbox. There is a second limit of 60 per minute per source IP address on the ingest endpoint, applied before we know which project a report is for — it bounds how much parsing one caller can make our servers do. A real reporter is nowhere near either; a load test from one machine will find the second one first. Both answer
429, and a refused report is not stored and not counted against your plan. - 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:
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— loadswidget.js.connect-src https://supportshot.com— fetches your project config and posts the report.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-srcchange is needed. The widget styles itself with a constructed stylesheet adopted into its shadow root, which is CSSOM rather than markup — nostyle-srcdirective applies to it. Understyle-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 nostyle-srcdirective 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, andSupportShot.identify()then throws aReferenceError. 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:
- 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 withwc -c stub.js. Most editors add a trailing newline when they save, and that extra byte changes the digest. - Hash it:
openssl dgst -sha256 -binary stub.js | openssl base64 -A - Prefix the output with
sha256-, wrap the whole thing in single quotes, and add it toscript-src— the result looks exactly like'sha256-B0b2m/Rq039fHL71unY5jkuMGEEGWDaxEBFuWZhi8RU='.
Assets served from another host
Taking the screenshot re-reads the page’s images, fonts, and stylesheets, so anything served from an origin other than your own has to be allowed — and it goes in two directives, not one, because the capture loads the two kinds of asset the way a browser does:
- Images are loaded as images.
img-srcis what allows them. The policy above saysimg-src 'self' data: blob:, which means a CDN’s images are blocked and come out of the capture as blank rectangles. Add the CDN origin toimg-src; no amount ofconnect-srcfixes it. (This page used to file images underconnect-src. It was wrong, and the policy printed above was the thing doing the blocking.) - Fonts and cross-origin stylesheets are read with an
XMLHttpRequest.connect-srcis what allows those. Aconnect-srcthat omits your font service, or the origin of a stylesheet you link, gives you a screenshot in the wrong typeface or without that sheet’s rules. - Either way nothing errors and the report still sends — the picture is just missing pieces. Most sites already list these origins for their own reasons; it is the site whose assets are only ever referenced from markup and CSS that gets caught.
With one CDN at https://assets.example.com serving both, the policy becomes:
Content-Security-Policy:
script-src 'self' https://supportshot.com 'sha256-B0b2m/Rq039fHL71unY5jkuMGEEGWDaxEBFuWZhi8RU=';
connect-src 'self' https://supportshot.com https://assets.example.com;
img-src 'self' data: blob: https://assets.example.com;Without the inline stub
If you would rather not allow any inline script, skip the stub and keep only the widget tag. There is then nothing to hold early calls, so anything you send before widget.js is ready is dropped rather than queued — and “ready” is later than it looks. Listen for supportshot:ready on window instead:
// No queue stub: wait for the widget to announce itself.
function identifyUser() {
window.SupportShot.identify({
id: "user_8241",
email: "priya@example.com",
name: "Priya Raman",
});
}
window.addEventListener("supportshot:ready", identifyUser);
// If widget.js finished before this file ran, the event is already past.
if (window.SupportShot) identifyUser();The widget fires that event on window exactly once, immediately after window.SupportShot becomes the live API, so a handler can call identify(), open() or close() straight away. It is fired only when the widget actually mounted: a disabled project, a refused config request, or any other reason the widget switches itself off leaves the event unfired, as it leaves everything else on your page untouched.
The two-line guard underneath the listener covers the other order. Your script may run after widget.js has already finished, in which case the event has been and gone; without the stub, window.SupportShot existing at all means the widget is loaded, so testing for it is the whole check. Only one of the two paths can run — the global is assigned and the event dispatched in the same turn, with nothing of yours able to observe the gap.
Do not use a load listener on the widget <script> tag for this. load fires when the file has executed, and at that point the widget is still waiting on its project configuration, so window.SupportShot does not exist yet. That is a network round trip early, and it is why this event exists.
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. Both have a ceiling, applied in the browser before anything is sent: capture work is bounded at 8 megapixels, and an encoded image over 4 MB is scaled down until it fits — half the 8 MB the API accepts per image, and the gap is deliberate rather than arithmetic. What the widget measures is the plain capture; the annotated copy is flattened and encoded afterwards and never re-measured, and ink over flat UI is what compresses worst, so the headroom is what that copy is allowed to grow into. It also keeps a two-image upload inside the 60-second send timeout on an ordinary connection. When a screenshot is scaled down the panel says so, while the reporter is still writing, rather than letting them finish a multi-megabyte upload and be told it was refused. An ordinary display is unaffected; only 5K and 6K panels reach the pixel ceiling.
- Console entries — the last 200, in one buffer shared between
log,info,warn,erroranddebugcalls, 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.
The 200 and the 50 are the widget’s ring buffers. The ingest API is deliberately looser, so a newer widget never starts failing against an older server: it accepts up to 2,000 console entries and 2,000 network entries per report, discards the rest, and takes the page-context object as an open set of keys rather than a fixed list. Whatever arrives inside those bounds is stored on the ticket and shown to you. The widget never sends more than 200 and 50 and never sends a key outside the list above; the larger numbers are what a hand-written client posting with your public key could send, which is one more reason to keep your allowed-origins list tight.
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
Authorizationheader or personal data in a JSON payload never leave the browser. That is a statement about headers and bodies rather than a general promise — the page URL is recorded in full and console output is kept exactly as it was printed, so a secret your own code puts in either of those does reach us, and the privacy policy sets out both paths. - 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 belong to one document: a page load starts them empty and leaving the page discards them. A single-page app never leaves the page, so they are emptied on two events of their own — when a report is accepted, and when
identify()names a different user than the one your page had already named. Between them, one visitor’s console output cannot end up on the next visitor’s report — but the second of those only fires if your site tells us, and the firstidentify()of a page load deliberately does not count (that is your auth library resolving for the only person who has been here, not a second person arriving). So callidentify()at sign-in andidentify({})at sign-out; a session that ends in silence leaves the previous reporter’s buffers in place, because nothing observable distinguishes it from that person still being there.
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, delete the workspace yourself: Org settings in the dashboard, owner only, confirmed by typing the workspace name. That erases the projects, every ticket, both copies of every screenshot, the recipient lists, the workspace, and the account of every member who has no other workspace. The erasure runs while you wait and finishes before the page comes back — there is no 30-day queue on it and no email to send. Your subscription is cancelled after that erasure, not before it. The same transaction records the cancellation we then owe Stripe, and we place it immediately afterwards — in the ordinary case within the same second. A cancellation that does not go through at once is retried rather than abandoned, so at worst one further billing period may be charged after the workspace is gone, and any such charge is refundable on request. The terms say what that ordering buys you and what the worst case costs. If you have lost access to the account, email support@supportshot.com from the address you signed up with and we will do the same by hand within 30 days.
- Nothing here is a substitute for a copy of your own, and you can take one at any time: a project’s settings page has an Export tickets section that downloads every report in that project as JSON or CSV. It is one project per download, it covers reports rather than project settings or recipient lists, and JSON is the lossless one — the page context, the console log and the network log are nested structures, and in the CSV each arrives as JSON text in a single cell. Screenshots are linked from the file rather than embedded in it. Exporting deletes nothing.
- A backup taken before a deletion still contains what it contained when it was taken. No backup is kept longer than 14 days; the privacy policy sets out what that means.
- 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 the named subprocessors and where each one processes, is in the privacy policy. If you are the controller of the reports you collect — you almost certainly are — the Article 28(3) terms are in the data processing agreement, which is already in force and needs no request. What secures it, and what does not — there is no encryption at rest — is on the security page.
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.
If it is not an installation question — billing, a privacy request, a security report, or getting a copy of your data out — the contact page lists which of those have a control or a page that answers faster than we can, and what to put in the message for the ones that do not.