Docs · raw markdown
Install
Three env values, one route behind your sign-in, one tag on signed-in pages. Ten minutes. The console tells you when the first report lands.
Shutterbug only accepts reports from signed-in people, so every host proves who is reporting: your server presents the project secret once and gets a short-lived token the widget carries. That is the route. Everything else is a tag.
Before you start
- Sign up at shutterbug.dev. A workspace and a first project are created on the first visit.
- Open Install. For another project, press New project: a name and the origins the widget will run on, one per line. The project's secret is shown once, on that page.
- Keep the Install page open. It flips when the token route answers and again when the first report lands.
List every origin the widget runs on, one per line, including http://localhost:3000 for development; a project can list up to ten. A capture from an origin that is not listed is refused.
1. Env
Three values from the Install page:
SHUTTERBUG_URL=https://shutterbug.dev
SHUTTERBUG_PROJECT_KEY=pk_…
SHUTTERBUG_PROJECT_SECRET=<the secret from the Install page>The key is public and sits in the tag. The secret is a server secret: put it in the deployment's environment, never in the repository.
2. Token route
Behind your own sign-in. Whoever this returns 200 to can report.
Next.js with Clerk
app/api/shutterbug/token/route.ts:
import { auth, currentUser } from "@clerk/nextjs/server";
export async function GET() {
const { userId } = await auth();
if (userId === null) {
return Response.json({ error: "sign_in_required" }, { status: 401 });
}
const user = await currentUser();
const url = process.env.SHUTTERBUG_URL + "/api/reporter-tokens";
const upstream = await fetch(url, {
method: "POST",
headers: {
authorization: "Bearer " + process.env.SHUTTERBUG_PROJECT_SECRET,
"content-type": "application/json",
},
body: JSON.stringify({
projectKey: process.env.SHUTTERBUG_PROJECT_KEY,
reporter: {
id: userId,
email: user?.primaryEmailAddress?.emailAddress ?? null,
},
}),
cache: "no-store",
});
return new Response(upstream.body, {
status: upstream.status,
headers: {
"content-type": "application/json",
"cache-control": "no-store",
},
});
}Next.js with next-auth
The same route with the session from auth():
import { auth } from "@/auth";
export async function GET() {
const session = await auth();
const user = session?.user;
if (!user?.id) {
return Response.json({ error: "sign_in_required" }, { status: 401 });
}
const url = process.env.SHUTTERBUG_URL + "/api/reporter-tokens";
const upstream = await fetch(url, {
method: "POST",
headers: {
authorization: "Bearer " + process.env.SHUTTERBUG_PROJECT_SECRET,
"content-type": "application/json",
},
body: JSON.stringify({
projectKey: process.env.SHUTTERBUG_PROJECT_KEY,
reporter: {
id: user.id,
email: user.email ?? null,
},
}),
cache: "no-store",
});
return new Response(upstream.body, {
status: upstream.status,
headers: {
"content-type": "application/json",
"cache-control": "no-store",
},
});
}To let only some people report (staff, say), add one if on the role your session already resolves and return 401 for the rest. Shutterbug never knows who was refused.
If a proxy guards every route (next-auth's authorized callback, Clerk's clerkMiddleware with auth.protect()), open this one path in it and let the route refuse for itself. A signed-out widget needs the route's own 401: a redirect to your sign-in page comes back as a 200 HTML document, which the widget reads as a server fault rather than sign in, and Check the route reads as answered 307.
Any other stack
The route is one POST. Token exchange has the contract and the route in Node, Laravel, Rails and ASP.NET.
Deploy it, then press Check the route on the Install page. It answers Route found, behind sign-in when a request with no session gets a 401, and says what is wrong otherwise.
3. Tag
On signed-in pages only, so the mark is never shown to someone who cannot report.
Next.js
In the layout that only signed-in people see:
import Script from "next/script";
<Script
src="https://shutterbug.dev/shutterbug/loader.js"
strategy="afterInteractive"
data-shutterbug-project-key="pk_…"
data-shutterbug-endpoint="https://shutterbug.dev"
data-shutterbug-token-url="/api/shutterbug/token"
data-shutterbug-release={process.env.VERCEL_GIT_COMMIT_SHA}
/>If your Content Security Policy uses a nonce with 'strict-dynamic', the browser ignores the origins listed in script-src and runs the loader only with the page's nonce. In the Next.js guide's setup the proxy puts the nonce on the x-nonce request header; read it in the layout and pass it to <Script>:
import { headers } from "next/headers";
import Script from "next/script";
export default async function SignedInLayout({
children,
}: {
children: React.ReactNode;
}) {
const nonce = (await headers()).get("x-nonce") ?? undefined;
return (
<>
{children}
<Script
src="https://shutterbug.dev/shutterbug/loader.js"
strategy="afterInteractive"
nonce={nonce}
data-shutterbug-project-key="pk_…"
data-shutterbug-endpoint="https://shutterbug.dev"
data-shutterbug-token-url="/api/shutterbug/token"
data-shutterbug-release={process.env.VERCEL_GIT_COMMIT_SHA}
/>
</>
);
}Without a nonce in your policy, the first snippet is all you need. The rest of the policy is under Content Security Policy in The widget.
Plain HTML
Before the closing body tag of every signed-in page:
<script src="https://shutterbug.dev/shutterbug/loader.js"
data-shutterbug-project-key="pk_…"
data-shutterbug-endpoint="https://shutterbug.dev"
data-shutterbug-token-url="/api/shutterbug/token"
data-shutterbug-release="<build id>"></script>data-shutterbug-release is the build id, so every report says which build it was made on. On Vercel that is VERCEL_GIT_COMMIT_SHA. A container or standalone build has no such variable, so pass whatever your own build writes (BUILD_SHA, say); without it every report says the same build. A <meta name="shutterbug-release" content="…"> in the head works too. Every other attribute is in The widget.
The loader is 3.5 KB gzipped with no dependencies. It draws nothing into your DOM: the mark and the bar live in a closed shadow root.
4. First report
Open the app signed in. The Install page flips to Token route answered. Press the mark in the corner, or Alt+S, point at something, write a line, send. The page flips to #001 logged with a link to the report. Installed.
If something is off
- The mark does not appear. The tag is on a page before sign-in, the token route path in the tag is wrong, or the loader was blocked. Check the browser's network tab for
loader.jsand/api/shutterbug/token. - The token route answers 401 to a signed-in person. The route is not reading the same session as the page, or the secret is wrong. Rotate it on Settings and set it again.
- Send fails with a network error. The app's origin is not listed for this project, or is listed with a different scheme or port. Origins are compared exactly:
https://app.example.com, notapp.example.com/. - The widget says This project is no longer taking reports. The project is archived: ingest and the token exchange answer 410. Restore it on the project's Settings; the key and the secret are unchanged.
- Check the route says unprotected. The route returned 200 to a request with no session. Put it behind your sign-in before anyone finds it.
For agents
Give your agent this file and the values from the Install page. The page's Copy for your agent button puts a prompt with the public values on the clipboard; the secret is never in it.