This page is the whole integration, in order: install it, initialise it, tell it who is reporting, then decide how much of the rest you want. Steps 1 to 3 are everything most products ever do.
You need two values before you start — an API key and a project id. If you do not have them yet, getting started is three screens in the dashboard.
1. Install
Pick whichever matches how your app is built. They are the same code.
2. Initialise
One call, as early in your app's life as you can manage. Three options are required; everything else has a default that is right for most products.
BugCatch.init({
apiBase: 'https://api.bugcatch.sphoro.com',
apiKey: 'bc_live_…', // Workspace Settings → API Keys
projectId: '…', // Project Settings → General
});
| Option | Required | Notes |
|---|---|---|
apiKey |
Required | Throws immediately if missing. Publishable — it is meant to be in your page source. |
projectId |
Required | Throws immediately if missing. The id, not the short project key. |
apiBase |
In practice, yes |
Defaults to http://localhost:3000, which is right for developing
against a local API and wrong in production. Set it explicitly.
|
The most common first-day mistake is leaving apiBase out. The
widget renders, the reporter presses Send, and the request goes to
localhost:3000 on their machine — where there is nothing. The report is
never filed and the dashboard shows nothing to explain why.
Two things worth knowing about init()
-
Calling it twice is ignored, with a warning in the console. A second call
does not reconfigure anything — to change options, call
destroy()and theninit()again. - It is synchronous and makes no request. A visitor who has never filed a bug costs one script parse and nothing on the network.
3. Where to put it, per framework
The rule in every case: initialise once, at the top level, on the client. The SDK
patches console, fetch and XMLHttpRequest, so
it needs a browser and it needs to be early.
4. Verify the install
Do this before you go further. Everything below assumes reports are arriving.
- A Report a bug launcher is visible in the bottom-right of your app.
- The browser console shows no
[BugCatch]warning or error on load. - Filing a test report closes the panel with a confirmation rather than an error.
- The report appears in the dashboard, in the project you expected.
- Its Console tab has your app's own log lines in it, and its Network tab has your app's own requests. If both are empty, the SDK loaded after everything interesting had already happened — move the call earlier.
- Its Environment tab shows the browser and viewport you filed from.
Test it from a real page, not from the console. Typing
BugCatch.report(…) into devtools works, but it tells you nothing about
whether your build shipped the call.
5. Tell BugCatch who is reporting
Optional, and the single highest-value line you will add. Without it, reports are anonymous and nobody ever hears what happened to them.
BugCatch.identify({ id: 'u_42', email: 'alex@acme.com', name: 'Alex' });
// And on sign-out — otherwise the next person at a shared machine
// inherits the previous one's reports.
BugCatch.identify(null);
BugCatch.forgetReports();
email is the field with behaviour attached. It does three things:
- Threads their reports together. Every report from that address belongs to one person in the dashboard, with a count and a last-seen — so "this customer keeps hitting things" is visible rather than something someone has to notice.
- Closes the loop. When the bug is marked resolved, that person gets the project's resolution email. Nobody has to remember to tell them.
-
Is trusted, because you authenticated them. An address typed into a public
form is unusable for outbound mail until its owner answers a code. One that arrives
through
identify()is not, because your app already knows who they are. Only pass addresses you have actually signed in.
Letting reporters prove the address themselves
identify() is your application vouching for its own user. Some products
want the address proved instead — the reporter types one, we email a code, they type
it back. That is a project setting, not an SDK option: a widget that could opt
out of a required sign-in would not be a requirement.
Open Project Settings → General → Reporter sign-in and pick one:
| Setting | What the widget does |
|---|---|
| Off (default) | No sign-in. An address only reaches a report through identify(). |
| Optional | Offers a sign-in above the form. Anyone who would rather not can still report anonymously. |
| Required | No form at all until they have signed in. Every report then carries a proved address. |
Two consequences worth planning for:
- One live session per address per project. Signing in on a phone signs that address out on the laptop. The widget notices on its next open and says so, rather than failing at the moment they press Send.
-
Where both exist, the session wins. On a project that requires sign-in, the
address on the report is the one that answered the code — never the one the page
passed to
identify().identify()still supplies the name and pre-fills the field, so signing in is one box and one code rather than two.
Driving it yourself, with widget: false:
const { mode, signedIn } = await BugCatch.loginState();
if (mode !== 'off' && !signedIn) {
await BugCatch.signIn('alex@acme.com'); // a code goes out
await BugCatch.confirmSignIn('alex@acme.com', code); // whatever they typed back
}
await BugCatch.signOut(); // ends it here and on the server
6. Options reference
Everything init() accepts. Defaults are what you get by leaving it out.
Identity and destination
| Option | Default | What it does |
|---|---|---|
apiKey | required | Workspace API key. |
projectId | required | Project the bugs land in. |
apiBase | http://localhost:3000 | Where the API lives. Set it. |
user | null | Same shape as identify(), if you have it at init time. |
The widget
| Option | Default | What it does |
|---|---|---|
widget | true | The floating launcher. false to drive it from your own UI. |
widgetPosition | 'bottom-right' | Also bottom-left, top-right, top-left. |
theme | 'auto' | auto follows the visitor's system setting; light / dark pin it. A switch sits above the launcher, and once a visitor uses it their choice is remembered and wins on later visits. |
accent | '#4F46E5' | Any CSS colour. Used for the launcher, focus rings and the primary button. |
knownIssues | true | Show what this project already knows is broken above the form, and offer “this is happening to me too” instead of a second report. Draws nothing until you switch it on for the project and something has been reported twice — see What is already known. |
Capture
| Option | Default | What it does |
|---|---|---|
captureConsole | true | Wrap console.*. |
captureNetwork | true | Wrap fetch and XMLHttpRequest. |
captureErrors | true | Listen for error and unhandledrejection. |
consoleLimit | 100 | Console entries retained. A ring buffer, so memory is bounded. |
networkLimit | 50 | Network entries retained. |
beforeSend | — | Mutate the payload, or return false to cancel the report. |
Attachments
| Option | Default | What it does |
|---|---|---|
attachments | true | Offer screenshot, screen recording and file attach. |
maxAttachments | 5 | Per report. |
maxAttachmentBytes | 25 MB | Per file. |
maxRecordingMs | 120000 | Hard cap on a single recording. |
The reporter loop
| Option | Default | What it does |
|---|---|---|
updates | true | Tell the reporter, live, when a bug they filed moves. Connects only once this browser has filed something. |
updateToast | true | Show the built-in notification. Set false to render your own. |
onUpdate | — | Called on every update, whether or not the built-in card is shown. |
Calling
| Option | Default | What it does |
|---|---|---|
voice | true | Offer calls in both directions, where the workspace has one configured. Nothing is dialled and no microphone is opened until somebody presses a button. |
onCallState | — | As a call moves between states. |
onCallTranscript | — | Each line of a call, as it is recognised. |
onIncomingCall | — | Someone on the team is ringing this page. The card is already drawn; this is for ducking your own audio. |
What is already known
The widget can open with the reports you have said are already broken, above the form rather than after it, with a Me too button beside each one. Somebody who came to report a thing you already know about says so in one tap and leaves — and you get one report carrying everybody’s environment instead of fourteen tickets carrying one each.
Nothing is in anybody’s way. The list is dismissed by ignoring it, and the form underneath is the form that was always there.
One switch, off until you set it:
- Project Settings → General → “Show what is already known before the report form” — whether this project publishes anything at all.
Which reports appear is then decided by the queue rather than by anybody remembering to tick a box. A report goes on the list once a second person has hit it — a report matched to it as a repeat, or a Me too — and comes off the moment it is resolved. Never on severity: how bad your team thinks something is is a judgement, and this publishes on facts.
That used to be a second, per-report switch, and the honest reason it is not any more is that the moment a report becomes worth publishing is the moment somebody reports it twice — which is a moment nobody on your team is present for. The list it produced was empty.
Anything that should not be public comes off on the report’s own page, and stays off however many more people report it.
This is not the same control as session visibility. A public session is readable by whoever holds its link, which you hand to one person on purpose. Listing puts the title in front of every visitor of your site.
Joining is a promise, and it is kept: everyone who pressed Me
too is told when the report is resolved, over the same live updates
and the same email as the person who filed it. That is why the widget asks
for an address when neither a sign-in nor identify() has given
it one.
const issues = await BugCatch.knownIssues();
// [{ id, title, status, affectedCount, since, joined }]
await BugCatch.meToo(issues[0].id, { email: 'them@example.com' });
// { joined: true, affectedCount: 4 }
What comes back is a title, a state and a count — never a severity, an assignee or a description. The question it answers is “is this the thing I came here about”, and your own estimate of how bad something is reads as a promise once it is published.
7. Reporting from your own UI
Turn the launcher off and call report() from your own button. Everything
captured still comes along.
BugCatch.init({ apiBase, apiKey, projectId, widget: false });
document.querySelector('#help').onclick = () =>
BugCatch.report({
title: 'Checkout button does nothing',
description: 'Clicked Pay, no request fired',
priority: 'HIGH', // LOW | MEDIUM | HIGH | CRITICAL
metadata: { plan: 'team', cartId: 'c_881' },
});
report() resolves to { id, redactionsApplied, watchToken, estimate },
or to { cancelled: true } if your beforeSend returned
false. It rejects if title is empty.
Attaching your own files
uploadAttachment() resolves the storage key a report references, so you
can build your own capture flow and still get media onto the bug. Bytes go straight to
storage, never through your own server.
const { storageKey, mime } = await BugCatch.uploadAttachment(
file, file.type, (fraction) => showProgress(fraction),
);
await BugCatch.report({
title: 'Layout breaks on the pricing page',
attachments: [{ type: 'SCREENSHOT', storageKey, mime }], // or 'VIDEO'
});
A report's attachments are typed SCREENSHOT or VIDEO, so they
accept image/png, image/jpeg, image/webp,
video/webm and video/mp4. Entries with an unusable key or an
unaccepted type are dropped rather than failing the whole report. Documents and voice
notes belong on a message, which takes more types.
What the built-in widget captures
Screenshot and screen recording both go through getDisplayMedia, so
the browser always shows its own picker and permission prompt — nothing is
captured without the reporter choosing what to share. The widget hides itself first so
the capture shows your page rather than the dialog, and a recording collapses the panel
to a timer bar with Stop and Discard.
Capture needs a secure context (HTTPS or localhost). Where
getDisplayMedia is missing the two capture buttons are simply not
rendered, and file attach still works.
8. Closing the loop with the reporter
The person who found the bug is the one a tracker usually forgets. They are already in
your app, so that is where they hear about it — no email to open, no page to remember.
This is on by default and needs no setup beyond identify().
Four kinds of update arrive, each with a summary already phrased for the reporter:
kind | When | Also carries |
|---|---|---|
status | open → in progress → fixed → closed | status |
severity | the team re-prioritised it | priority |
assignment | someone picked it up, or put it back | assigned |
reply | the team sent them a message | reply.body, reply.author |
Internal comments never arrive. Only a comment a team member explicitly marked
as visible to the reporter becomes a reply. And the assignee is never
named: assigned: true says the report has an owner; who that is stays
inside your workspace.
Rendering it yourself
Nothing here is on a timer. Each update is held until it is marked read — and read is something the reporter does, not something a clock decides — so if you replace the built-in card, your UI is the thing that has to say when.
BugCatch.init({
apiKey, projectId,
updateToast: false,
onUpdate: (u) => myToast(u.summary, () => BugCatch.markUpdateRead(u.id)),
});
// The number for your own bell: fires on every arrival and every mark-read.
BugCatch.on('unread', ({ count }) => setBadge(count));
BugCatch.unreadUpdates(); // [{ id, bugId, summary, kind, at }, …] oldest first
BugCatch.markUpdateRead(id); // one — what your own dismiss should call
BugCatch.markReportRead(bugId); // everything about one report, when they open it
BugCatch.markUpdatesRead(); // clear all
Prefer summary over building the sentence yourself — the API writes it, so
your card, the widget's timeline and the reporter portal all say the same thing.
Their own history, and writing back
Once this browser has filed something, the widget grows a Your reports button listing everything they reported and where each one got to. Opening one opens a conversation: the team's replies and theirs, either side of the quiet status lines, with a box that takes text, files, images and a voice note.
const { reports } = await BugCatch.myReports();
// [{ id, reference: '3F2A9B01', title, status, priority, createdAt, project, attachments }]
if (BugCatch.hasReports()) showMyReportsLink(); // hide it until it has content
const report = await BugCatch.myReport(id); // full timeline, both sides
await BugCatch.replyToReport(id, {
body: 'Still empty on page 3',
attachments: [{ ...uploaded, name: file.name, size: file.size }],
});
Body or attachments — at least one, or the API refuses it. Messages take more types
than a report does: alongside images and video, a message carries audio/*
voice notes and documents (PDF, txt, csv, json, zip, doc(x), xls(x)).
The timeline is stored server-side, so someone who reports a bug on Monday and checks
back on Friday sees the triage and the reply, not just "fixed".
report.canReply says whether this reader can write back; the widget hides
its composer when it is false rather than offering a box that would fail on send.
Call BugCatch.forgetReports() on sign-out. The subscription lives
in localStorage; without this, the next person at a shared machine
inherits the previous one's notifications. It takes the unread ones with it.
9. Voice calls
Where the workspace has sphoro.voice configured, a reporter can talk instead of type.
Most deployments do not have this — canCall() and
canCallNow() answer false, no button is drawn, and you can
skip this section entirely.
There are two different features here, and a deployment offers one or the other:
| Triage agent | Live call | |
|---|---|---|
| Started with | startCall() | startLiveCall(bugId) |
| Who answers | Software, and it says so | A person on your team |
| Recorded | Yes — the team reads the transcript | No. Peer-to-peer over WebRTC; the thread gets a note that it happened |
| Can go unanswered | No | Yes — missed after about twenty seconds |
| Needs an existing report | No — it files one and calls about it | Yes |
// Which kind this deployment offers, or null for neither.
const kind = await BugCatch.callKind(); // 'agent' | 'live' | null
if (await BugCatch.canCallNow()) {
const call = await BugCatch.startCall(); // files a report, then calls about it
call.bugId; // what it was filed as
}
BugCatch.endCall(); // hang up, either kind
Three things customers always ask, worth answering in your own UI too:
- The microphone is asked for on the press, never before. An offer that raised a permission prompt on arrival would be a page listening to somebody who walked away from it.
-
One call at a time, because there is one microphone.
destroy()ends it. - An incoming call is an offer, not a connection. When the team rings a page, the SDK draws a card naming the caller, the report, and whether the call would be recorded — and nothing opens until Answer is pressed.
Being called requires a proved address. The team's button is refused unless
the reporter signed in through the widget or the portal. An address that only arrived
through identify() is your app vouching for its own user, which is not
a claim anybody outside your app can rely on. A project with reporter sign-in
Off therefore has no callable reports.
Set voice: false to opt out entirely. The code stays in the bundle — about
1.5 kB gzipped, most of it a codec browsers do not ship — but nothing runs and no card
is ever drawn.
10. Keeping secrets out of reports
Three layers, and the first one you get without doing anything.
Credential scrubbing, always on
Request and response bodies are never read, which leaves the URL as the place
credentials actually turn up. Every one of these is replaced with
[REDACTED] before the entry enters the ring buffer — so the secret
never leaves the page, and is not recoverable from memory either:
https://api.example.com/v1/me?api_key=abcd1234efgh5678&page=2
↓
https://api.example.com/v1/me?api_key=[REDACTED]&page=2
Recognised without configuration: parameters named for a credential, JWTs, S3
presigned signatures, OAuth fragments, user:password@host, and the key
formats used by AWS, Google, GitHub, GitLab, Stripe, Slack, Shopify, OpenAI and
Anthropic. Ordinary parameters — ?page=2, ?sort=created_at —
are left alone, because a Network tab that blanks whole URLs is one nobody can triage
with, and the usual response to that is to switch capture off.
Your own rules, in the browser
BugCatch.init({
apiKey, projectId,
beforeSend: (payload) => {
payload.logs.network.entries = payload.logs.network.entries
.filter((e) => !e.url.includes('/internal/'));
delete payload.metadata.user;
return payload; // or `false` to cancel the report entirely
},
});
Your own rules, on the server
Workspace Settings → Security holds redaction rules that run as well, so a secret is caught even when a client forgets or an old bundle is still cached somewhere. Redaction runs before encryption, so the stored row never contains the secret. The same scrubbing is applied again when a report is read back, which covers reports captured by older SDK versions.
What is captured, in full
| Group | Contents |
|---|---|
| Console | The last consoleLimit entries with level and timestamp, plus uncaught errors and unhandled promise rejections. Each message truncated to 2,000 characters. |
| Network | The last networkLimit fetch/XHR calls: URL, method, status, duration, and flags for failed or slow. No request or response bodies, and no headers. |
| Environment | User agent, platform, language(s), viewport, screen, DPR, timezone, connection type, CPU cores, device memory, touch points, battery, colour-scheme and reduced-motion preferences, Do Not Track, online state, referrer, page title. |
| Page URL | At the moment of reporting, scrubbed the same way. |
11. Content Security Policy
If your app sends a CSP, these are the directives the SDK needs.
| Directive | Add | Why |
|---|---|---|
connect-src |
https://api.bugcatch.sphoro.com |
Filing reports, the update stream, and attachment uploads — which are proxied through the API, so this one host covers all of it. |
script-src |
https://unpkg.com |
Only if you load the bundle from the CDN. Serve it yourself and you need nothing here. |
style-src |
'unsafe-inline' |
The widget builds a <style> element inside its shadow root. There is no nonce option today. |
img-src |
blob: data: |
Screenshot previews are held as blobs before upload. |
media-src |
blob: |
Only if you keep screen recording on. |
If style-src 'unsafe-inline' is not acceptable in your
application, run with widget: false and call
report() from your own UI. Capture, uploads and the reporter loop all
work without the widget — it is only the rendered panel that needs the stylesheet.
Nothing else is fetched: fonts are the system stack and every icon is inline, so there
is no font-src or third-party img-src to add. And there is
nothing to allowlist on our side — the /ingest/ routes accept any origin
by design.
12. Troubleshooting
Every failure below writes something to the browser console. Open it first — the message usually names the problem outright.
| What you see | What it is | Fix |
|---|---|---|
[BugCatch] apiKey is required. |
init() threw. Nothing is running. |
The value is undefined at the point you call init() — usually an env variable without the framework's public prefix. |
[BugCatch] projectId is required. |
Same. | Check you pasted the project id, not the short project key. |
[BugCatch] init() called twice |
A warning. The second call was ignored. | Harmless under React StrictMode. If you meant to reconfigure, destroy() first. |
| No launcher appears at all | Either widget: false, or the script never ran. |
Check the Network tab for the bundle, and that no earlier error stopped your entry module. |
Send fails, request went to localhost:3000 |
apiBase was left out. |
Set it to https://api.bugcatch.sphoro.com. |
401 on /ingest/bugs |
The key is wrong, or revoked. | Issue a new one under Workspace Settings → API Keys. Keys cannot be recovered, only replaced. |
403, "workspace is suspended" |
The key is valid; the workspace is not ingesting. | Retrying will not help. Contact support. |
400, "That project does not belong to this API key" |
The project id is from a different workspace. | Take both values from the same workspace. |
401, "Sign in to report a bug on this project" |
The project requires reporter sign-in and this browser has no live session. | Either sign the reporter in, or set Reporter sign-in to Off/Optional. |
| Console and Network tabs are empty on every report | The SDK initialised after the interesting activity. | Move init() earlier — ideally the first thing your entry module does. |
| The two capture buttons are missing | No getDisplayMedia — an insecure context, or a browser without it. |
Serve over HTTPS. File attach still works meanwhile. |
| The widget renders unstyled | CSP blocked the shadow-root stylesheet. | Allow style-src 'unsafe-inline', or run widget: false. |
| Reports arrive with no reporter | identify() was never called, or was called with no email. |
See step 5. Nobody gets a resolution mail without it. |
| An update card shows the previous user's bug | forgetReports() was not called on sign-out. |
Call it alongside identify(null). |
Removing it cleanly
BugCatch.destroy(); // restores console.*, fetch and XMLHttpRequest; ends any call
BugCatch.clear(); // empties the capture buffers without tearing down
destroy() leaves the page exactly as it found it, so a later
init() starts fresh rather than stacking a second widget.
Design notes worth knowing
- The widget renders in a shadow root, so your CSS and its CSS cannot collide.
- Capture never throws into your page. Every hook is wrapped and the originals are always called, so a bug in the SDK cannot break your app's logging.
-
Uploads use pristine
fetch/XHR references taken before the network hooks are installed — so a report never ends up describing its own upload. - Both buffers are ring buffers. A page left open all day does not grow memory without bound.
-
The panel traps focus, closes on Escape, restores focus to the
launcher, and honours
prefers-reduced-motion. - Nothing connects until this browser has filed something. A visitor who never reports costs no requests at all.
Still stuck?
Every answer here describes what the product actually does today. If something behaves differently, that is worth telling us about — it means the page is wrong.
- Support
- support@sphoro.com
- Security
- info@sphoro.com
- Talk to us
- Book 20 minutes