Documentation
Everything BugCatch does, and how to switch it on.
Install an SDK, paste a DSN, ship. This page covers the whole surface - capture APIs, the ingest format, alert timings, and the REST API behind the dashboard.
Quickstart
Three steps, about five minutes. You need an account on admin.bugcatch.app.
-
01
Create a project
One project per application. Creating it returns a DSN - the URL your SDK posts to.
-
02
Install the SDK
npm install bugcatch-sdk -
03
Initialise it once, as early as possible
Before your app renders or your server starts handling requests, so errors during startup are caught too.
import BugCatch from 'bugcatch-sdk';
BugCatch.init({
dsn: import.meta.env.VITE_BUGCATCH_DSN,
release: import.meta.env.VITE_APP_VERSION,
environment: import.meta.env.MODE,
});
That is the whole integration. Uncaught errors and unhandled promise rejections are now captured automatically.
The DSN
A DSN identifies the project and authenticates the SDK. It is shown on the project page in the dashboard and has this shape:
https://api.bugcatch.app/ingest/{projectId}?key={sdkKey}
The SDK key is public by design
It ships inside browser and mobile bundles, so treat it as an identifier rather than a secret. It only permits writing events to one project - it cannot read issues or touch any other project. If it is being abused, rotate it from the project page; the old key stops working immediately and you get a new DSN.
Keep the DSN in an environment variable rather than committing it, so staging and production report into separate projects.
Send a test event
To confirm the wiring end to end without waiting for a real crash, throw one deliberately:
BugCatch.captureMessage('BugCatch wired up', 'info');
// or a real exception
throw new Error('Test error from setup');
Ingest replies immediately and hands the event to a background worker, so allow about a
second before it appears under Issues. If nothing arrives, check the
browser network tab for the /ingest request - a 401 means the
SDK key is wrong or was rotated, and a 404 means the project ID is.
Fingerprinting
Every incoming event is reduced to a SHA-256 fingerprint of its error type, message, and location. That fingerprint is what makes the difference between an inbox with one message and an inbox with four thousand.
| Fingerprint | What happens |
|---|---|
| Not seen before | A new issue is opened and a notification goes out. |
| Already open | Counters increment, the user is added to the affected set, no new notification. |
| Belongs to a resolved issue | The issue reopens as a regression and you are notified again. |
Because the message is part of the fingerprint, errors that interpolate a value -
User 4821 not found - split into one issue per value. Keep dynamic values out
of the message and pass them as extra context instead:
// Splits into one issue per user
throw new Error(`User ${userId} not found`);
// One issue, with the id kept as context
BugCatch.captureException(new Error('User not found'), { userId });
Issues vs events
Event
One occurrence. Carries its own timestamp, stack trace, breadcrumbs, user, release, tags, and request context. This is the raw record.
Issue
The group all events with the same fingerprint belong to. Holds the status, assignee, comments, total event count, and affected-user count. This is what you triage.
You resolve issues, not events. Opening an issue shows its event histogram and lets you drill into any individual event for the exact state at that moment.
Levels & statuses
Levels
Severity of the event. Set it on manual captures; automatic captures are error.
| Level | Use for |
|---|---|
| fatal | The process or app died. |
| error | An operation failed. The default for uncaught exceptions. |
| warning | Degraded but recovered - a retry succeeded, a fallback kicked in. |
| info | Notable but not a problem. |
Statuses
Where an issue sits in triage. You set these from the dashboard.
| Status | Meaning |
|---|---|
unresolved | Open. The default for anything new. |
resolved | Fixed. Reopens automatically as a regression if the fingerprint returns. |
ignored | Known and accepted. Stays quiet - permanently, or until a snooze condition you set expires. See below. |
merged | An alias. Its events now live under another issue. Set only by merging - see below - and hidden from the default list. |
Ignore is for noise you cannot fix
Browser extension errors and third-party script failures are the usual candidates. If
the noise is predictable, dropping it in ignoreErrors or
beforeSend is better - it never leaves the client and never costs you an
event.
Issue lifecycle
resolved and ignored don't have to mean forever.
PATCH /issues/:id takes the status plus an optional condition for leaving it
early - useful for the two cases that make "resolved" and "ignored" too blunt on their
own: a fix that's still rolling out, and noise you want to hear about again if it gets
worse.
Snooze an ignored issue
Set one of these when you ignore an issue. Leave both out and it's ignored the normal way - forever, until you touch it again.
| Field | Un-ignores when |
|---|---|
ignoreDurationMinutes | That much time has passed. |
ignoreEventCount | The issue's event count has grown by that many more, from wherever it stood when you snoozed it. |
PATCH /projects/:projectId/issues/:id
{ "status": "ignored", "ignoreDurationMinutes": 1440 } // quiet for 24h
Whichever condition you set is checked on every new event against that issue. Once it's met, the issue un-ignores and goes through the same regression notification as any other reopened issue - to whoever gets notified, a snoozed issue waking back up looks identical to a resolved one coming back.
Resolve in next release
A plain resolve reopens on the very next matching event, regardless of where it came from.
That's usually right, but not while you're mid-rollout - the old build is still out there
producing the exact crash you just fixed. Add resolveInNextRelease and the
issue only reopens once an event actually carries a release newer than the one you
resolved it on.
PATCH /projects/:projectId/issues/:id
{ "status": "resolved", "resolveInNextRelease": true }
Bulk actions always mean forever
PUT /issues/bulk doesn't take a snooze or release condition, and clears any
existing one on the issues it touches. A bulk ignore is always "ignored, full stop" -
set a condition one issue at a time from the issue page if you want it to expire.
Merge issues
For when the default grouping splits one real problem into several issues that a grouping rule can't cleanly express. Merging folds one or more issues into another: their events move over, counters combine, and the merged-away issue sticks around as an alias rather than disappearing.
PUT /projects/:projectId/issues/merge
{ "issueIds": ["secondary-1", "secondary-2"], "primaryIssueId": "primary" }
Every future event that would have matched a merged-away fingerprint redirects straight to the primary instead - including reopening it if it's resolved, exactly as any other matching event would. Affected-user counts are combined properly rather than added, so a user who hit both issues is still counted once. Merging is always exactly one hop deep: you can't merge into an issue that's itself already a merge target.
POST /projects/:projectId/issues/:id/unmerge
Reverses it for that one issue: its events move back, both issues' counters and affected-user
counts are recomputed from scratch rather than reversed (undoing a union of user sets isn't
possible any other way), and it returns to unresolved regardless of what it was
before the merge. Unmerging one secondary never touches events from any other issue merged
into the same primary.
Inbound filters
Drop noise before it ever becomes an issue, from Project → Settings. A filtered event costs nothing - it's checked before quota is charged and before it reaches the queue, so it never counts against your monthly event limit.
| Filter | Drops when |
|---|---|
messagePatterns | The error message matches one of your regexes (case-insensitive). Checked at save time, so a typo in the pattern is rejected immediately, not silently ignored forever. |
allowedDomains | The event carries a request URL outside this allowlist. *.example.com matches any subdomain. Only applies when a request URL is present - backend errors are never touched by this filter. |
filterLegacyBrowsers | The User-Agent is a known legacy signature - old IE, old Android WebView, old iOS Safari. |
filterLocalhost | The event's user IP is loopback or a private range. |
PATCH /projects/:id/inbound-filters
{ "filterLocalhost": true, "messagePatterns": ["ResizeObserver loop.*"] }
This is a merge, not a replace - send only the fields you want to change. A config change takes effect on the very next event; nothing is cached against the old settings.
Custom grouping rules
The default fingerprint - type::message::location - splits
"User 123 not found" and "User 456 not found" into two issues,
because the message differs. A grouping rule overrides one dimension of the fingerprint so
variants like that collapse into one issue the way they should.
PUT /projects/:id/grouping-rules
[
{ "field": "message", "pattern": "User \\d+ not found", "groupKey": "user-not-found" }
]
field is type, message, or filename (the
last in-app stack frame). When pattern matches, only that one field's
contribution to the fingerprint is replaced with groupKey - the other two
dimensions are untouched, so the same message at a different file and line still gets its
own issue unless a second rule targets filename too.
Order matters - this replaces the whole list
Rules are evaluated in array order, first match wins, and PUT replaces the
entire list rather than merging - send every rule you want kept, not just the new one, the
same way you would reorder any other array.
Ownership rules
Auto-assign a brand-new issue to whoever owns that part of the codebase -
src/billing/** → @marko - instead of it landing unassigned for someone to
triage and hand off.
PUT /projects/:id/ownership-rules
[
{ "pattern": "src/billing/**", "userId": "..." }
]
pattern is a gitignore-style glob matched against the filename of the issue's
last in-app frame - ** crosses path segments, a single * stays
within one. First match wins, same replace-the-whole-list rule as grouping rules above.
Only applied once, at creation
A rule assigns an issue the moment it's first created and is never re-evaluated on later events for that same issue - so reassigning it by hand afterward is never silently undone by the rule firing again. Removing someone as a project member doesn't retroactively touch rules that already point at them.
Search & saved views
The issue list takes a small query language, not just the status/level dropdowns:
is:unresolved level:error release:1.2.3 user.email:*@acme.com checkout failed
| Token | Matches |
|---|---|
is: | Status - unresolved, resolved, ignored. |
level: | Issue level. |
release: | Matches either firstRelease or lastRelease. |
environment: / env: | Event environment. |
assigned: | me, none, or a member's email. |
user.email: | Supports a * wildcard - *@acme.com. |
Anything else, including a key:value token that isn't in the list above, is
treated as literal search text against the issue title - never silently dropped. The
dashboard's own status/level dropdown filters still exist independently and win over
is:/level: in the query if both are set, so a saved search can
still be narrowed further without editing it.
Sort with ?sort=recent|new|frequency|users (default recent) -
mapped to last seen, first seen, event count, and user count.
Saved searches
POST /projects/:projectId/saved-searches stores a
{ name, query, sort } you can reload later. Saved searches are personal - only
you see the ones you create, even though the project itself is shared with your team.
Breadcrumbs
Breadcrumbs are the trail of what happened before the crash. The browser SDK records them
automatically while autoCaptureBreadcrumbs is on, keeping the most recent
maxBreadcrumbs (default 100) in memory and attaching them to any event it sends.
| Source | Category | Recorded |
|---|---|---|
| DOM clicks | ui.click | Element tag, text, id and class |
| History navigation | navigation | The URL navigated to |
console.warn / console.error | console | The message text |
Add your own for anything domain-specific:
BugCatch.addBreadcrumb({
timestamp: new Date().toISOString(),
type: 'user',
category: 'auth',
message: 'User logged in',
data: { method: 'google-oauth' },
});
Breadcrumbs leave the device
Click breadcrumbs capture element text, which on a form can include what someone typed.
Turn off autoCaptureBreadcrumbs on screens handling payment or health data,
or strip them in beforeSend.
Releases & source maps
Set release on init and every event records which build it came from. That is
what lets you tell a new regression from something that has been broken for months, and it
is the key source maps are matched on.
Minified production stacks are unreadable on their own. Upload the source map for a release from Project → Source Maps and frames resolve back to the original file, line, and column.
| Field | Notes |
|---|---|
releaseVersion | Must match the release your SDK sends, exactly. |
fileName | The bundle the map belongs to, e.g. index-a1b2c3.js. |
| Map file | The .map file your bundler emits. |
Upload before you deploy
Resolution happens when the event is processed, not when you view it. A crash that arrives before its map is uploaded keeps the minified trace permanently, so make the upload part of your build step.
JavaScript / TypeScript
Ships as ESM and CJS, works with any bundler, has zero runtime dependencies and is about 15 KB minified. The same package covers browser and Node.
Options
| Option | Type | Default | Description |
|---|---|---|---|
dsn | string | required | Project DSN. |
release | string | - | App version, e.g. 1.2.3. |
environment | string | - | production, staging, … |
debug | boolean | false | Print SDK logs to the console. |
maxBreadcrumbs | number | 100 | Breadcrumbs kept in memory. |
autoCaptureErrors | boolean | true | Attach global error handlers. |
autoCaptureBreadcrumbs | boolean | true | Record clicks, navigation, console. |
ignoreErrors | (string|RegExp)[] | [] | Drop errors whose message matches. |
ignoreUrls | (string|RegExp)[] | [] | Drop errors from matching script URLs. |
beforeSend | (event) => event|false | - | Modify or drop an event before it is sent. |
Capture
// An Error object, with extra context
try {
await processOrder(order);
} catch (err) {
BugCatch.captureException(err, { orderId: order.id });
}
// A plain message: (message, level?, extra?)
BugCatch.captureMessage('Quota limit reached', 'warning', { used: 95 });
User context and tags
Set the user after login so issues show who was affected. Tags are arbitrary key–value pairs you can filter and aggregate by.
BugCatch.setUser({ id: '42', email: 'jane@example.com', username: 'jane' });
BugCatch.setTag('plan', 'pro');
BugCatch.setTag('region', 'eu-west-1');
BugCatch.clearUser(); // on logout
React error boundary
React swallows render errors into error boundaries, so they never reach the global handler. Report them explicitly:
class ErrorBoundary extends React.Component {
componentDidCatch(error: Error) {
BugCatch.captureException(error);
}
render() {
return this.props.children;
}
}
Vue
BugCatch.init({ dsn: import.meta.env.VITE_BUGCATCH_DSN });
app.config.errorHandler = (err) => {
BugCatch.captureException(err);
};
Hot reload and SPAs
init() is a singleton; calling it twice logs a warning and does nothing. During
HMR, tear down first so listeners are not attached repeatedly:
BugCatch.destroy(); // removes listeners, resets the singleton
Node.js
Same package. Turn off breadcrumb auto-capture, since there is no DOM to observe.
import BugCatch from 'bugcatch-sdk';
BugCatch.init({
dsn: process.env.BUGCATCH_DSN,
release: process.env.npm_package_version,
environment: process.env.NODE_ENV,
autoCaptureBreadcrumbs: false,
});
// Express error middleware - register it last
app.use((err, req, res, next) => {
BugCatch.captureException(err, { path: req.path, method: req.method });
next(err);
});
CommonJS uses a named import:
const { BugCatch } = require('bugcatch-sdk');
BugCatch.init({ dsn: process.env.BUGCATCH_DSN });
React Native
Works with Expo Go, the Expo managed workflow, and the React Native CLI. No native modules, so no rebuild and no config plugin.
npm install bugcatch-react-native-sdk
import BugCatch from 'bugcatch-react-native-sdk';
BugCatch.init({
dsn: 'https://api.bugcatch.app/ingest/PROJECT_ID?key=SDK_KEY',
release: '1.0.0',
environment: 'production',
});
The capture API is identical to the web SDK. Two options are specific to mobile:
| Option | Type | Default | Description |
|---|---|---|---|
autoTrackRequests | boolean | false | Intercept fetch() and send timing metrics. |
trackIgnoreUrls | (string|RegExp)[] | [] | URLs to exclude from request tracking. |
Auto-captured breadcrumbs here are console.warn/console.error calls
and app state changes, rather than DOM clicks.
Java
Java 21 or newer, thread-safe, zero runtime dependencies, with full support for exception cause chains.
<dependency>
<groupId>dev.lzrvc</groupId>
<artifactId>bugcatch-java-sdk</artifactId>
<version>0.1.0</version>
</dependency>
Gradle: implementation 'dev.lzrvc:bugcatch-java-sdk:0.1.0'
import dev.lzrvc.bugcatch.BugCatch;
import dev.lzrvc.bugcatch.BugCatchOptions;
BugCatch.init(new BugCatchOptions.Builder(System.getenv("BUGCATCH_DSN"))
.release("1.0.0")
.environment("production")
.build());
After init() the SDK installs a
Thread.setDefaultUncaughtExceptionHandler and captures uncaught exceptions
automatically.
// Manual capture with context
BugCatch.captureException(e, Map.of("orderId", orderId));
BugCatch.captureMessage("Disk usage above 90%", "warning");
// User context and tags
BugCatch.setUser(new UserContext.Builder().id("u123").email("a@b.com").build());
BugCatch.setTag("service", "order-service");
Options mirror the JavaScript SDK - debug, maxBreadcrumbs,
autoCaptureErrors, beforeSend - plus
ignoreError(String) for literal matches and
ignoreErrorPattern(String) for regular expressions.
Server metrics
BugCatchServerReporter periodically reports process memory, CPU, and event
loop lag. It is framework-agnostic - start it on boot and stop it on shutdown.
import { BugCatchServerReporter } from 'bugcatch-sdk';
const reporter = new BugCatchServerReporter({
dsn: process.env.BUGCATCH_DSN,
});
reporter.start();
// reporter.stop() on shutdown
| Option | Default | Description |
|---|---|---|
dsn | required | Same DSN used for error capture. |
reportInterval | 30000 | How often a snapshot is sent, in ms. |
instanceId | os.hostname() | Which instance a snapshot came from. Set it when several pods sit behind a load balancer. |
debug | false | Print SDK logs. |
Set instanceId when you run more than one instance
Without it, snapshots from every pod share a hostname-derived label and a single leaking instance looks like fleet-wide noise. The value appears on the dashboard and in threshold-alert emails.
In NestJS, wire it to the module lifecycle:
@Injectable()
export class BugCatchMetricsService implements OnModuleInit, OnModuleDestroy {
private readonly reporter = new BugCatchServerReporter({
dsn: process.env.BUGCATCH_DSN,
});
onModuleInit() { this.reporter.start(); }
onModuleDestroy() { this.reporter.stop(); }
}
Distributed tracing
One level below server metrics: instead of "the server was under load," tracing tells you which request, and which part of it - a slow query, a slow outbound call - was responsible. Pro and above.
A transaction is the root of one traced operation - an HTTP request, a
background job. It carries zero or more spans, each a timed unit of work
inside it: a DB query, an outbound call. Spans nest via SDK-generated
spanId/parentSpanId, which is what lets the dashboard rebuild
the waterfall view from a flat list without an extra query.
| Endpoint | Returns |
|---|---|
GET /tracing/stats?hours=24 | Per-route p50 / p75 / p95 / p99, throughput, error rate. |
GET /tracing/transactions | Paginated list - filter by name, op, environment, since. |
GET /tracing/transactions/:id | One transaction with its spans, for the waterfall. |
On a plan without tracing, ingest is silent, not an error
If tracing isn't on your plan, POST /transactions answers
200 { status: "skipped" } instead of rejecting the call - the same choice
server-metric ingestion already makes. An SDK on a downgraded plan doesn't start
spamming its own console; the data just isn't collected.
Percentiles are computed from the most recent 5,000 rows in the requested window, not a database-level aggregate - representative for a dashboard, though on a route busy enough to exceed that cap within the window, treat the tail percentiles as approximate.
Custom dashboards
A named, project-scoped grid of widgets built from the analytics you already have. Pro and above.
Every widget's source is one of your existing analytics endpoints -
overview, trend, events-over-time,
new-issues-over-time, issues-by-level, issues-by-status,
top-issues, or server-metrics. There's no separate query engine:
a dashboard just arranges charts that already exist into one view instead of tabbing between
Analytics and Server.
PUT /projects/:projectId/dashboards/:id/widgets
{ "widgets": [
{ "source": "trend", "x": 0, "y": 0, "w": 2, "h": 1 },
{ "source": "top-issues", "x": 0, "y": 1, "w": 1, "h": 1 }
] }
x/y/w/h are integer grid cells, not
pixels - the layout snaps to a grid rather than allowing free placement. Rearranging the
grid replaces the entire widget list in one call, same as grouping and ownership rules:
position is part of the saved state.
Shared with the project, not personal to you
Unlike saved searches, a dashboard has no owner - every project member sees the same list and the same layout. It's a view onto the project's data, the same way the Analytics tab already is, not a private preference.
Uptime & domain monitors
Add an HTTP monitor per URL under Project → Monitors. No SDK involved - checks run from BugCatch.
| Behaviour | Value |
|---|---|
| Check interval | Every 5 minutes |
| Request timeout | 10 seconds |
| Retries before marking down | 3, spaced 5 seconds apart |
| Uptime window | Rolling 24 hours |
| Statuses | up · down · unknown |
The retries matter: a single dropped packet does not page anyone. A URL is only reported down after it fails the initial check and all three retries. Response times are recorded for every check, so you can see a service degrading before it fails.
SSL certificate & domain expiry
Turn on checkSsl on an https monitor and each 5-minute check also reads the
certificate's expiry date - no separate schedule, no extra check to configure. You're
warned once it has 30 days or less left, one email per 24 hours rather
than a separate one at the 30, 14, and 7-day marks - the email states the exact day count
so you're not left guessing how urgent it is.
The warning clears itself once the certificate is renewed - if the days remaining go back above 30, the next expiry is treated as a fresh event, not silenced by the old alert. A non-https URL, or a check that can't complete a TLS handshake at all, is skipped rather than reported as an error: a broken certificate check is not the same claim as a down monitor, and reachability is already covered by the regular check.
checkDomain is the same idea for the domain registration itself, and it's a
separate opt-in from checkSsl on purpose - a certificate can auto-renew via
Let's Encrypt while nobody notices the domain registration lapsing underneath it. Same
30-day warning window, same 24-hour cooldown that clears on renewal, separate from the SSL
check's own cooldown so an expiring certificate and an expiring domain never share a timer.
Uptime and cron monitors share one ceiling
Your plan's monitor limit counts both together - three uptime checks and two heartbeats is five against a Starter plan's limit of three, not eight. Both are "a thing BugCatch watches for you," so they draw from the same number.
Cron monitors
A heartbeat monitor, for jobs BugCatch can't reach on its own - a nightly export, a queue worker, anything on a schedule rather than behind a URL. Where an uptime monitor polls you, a cron monitor waits for your job to check in - the same "dead man's switch" pattern Healthchecks.io popularized.
POST /cron/:cronMonitorId/ping
Call this when the job finishes. GET works too, so a plain curl or wget
one-liner in the crontab itself is enough - no SDK, no auth header. The URL's
unguessable id is what stands in for a secret, the same pattern the ping endpoint for
uptime monitors doesn't need because it's BugCatch calling out, not in.
| Endpoint | Use |
|---|---|
POST/GET /cron/:id/ping | Report success. This is the only call most jobs need. |
POST /cron/:id/start | Log that a run started, without changing up/down status - useful for long jobs where you also want to see run duration. |
POST /cron/:id/fail | Report failure explicitly, instead of waiting for the next ping to simply not arrive. |
| Setting | Meaning |
|---|---|
intervalMinutes | Expected time between pings - how often the job is supposed to run. |
graceMinutes | Extra time allowed past the interval before a missed ping alerts. Default 5. |
A job that pings every hour with a 5-minute grace period is reported down if
66 minutes pass with no ping. GET /cron-monitors/:id/pings returns the ping
history - every start, success, and failure, with duration where reported - for spotting a
job that's technically still checking in but taking steadily longer each run.
Status pages & incidents
A public page at bugcatch.app/status/:slug - no login, no plan gate on the
viewer's side - showing the uptime and cron monitors you choose to put on it, plus any
incident you post. One page per project.
POST /status-page/monitors links either kind of monitor onto the page by id
and type, with an optional label that overrides the monitor's internal name -
so prod-health-check-v3 can read "API" in public. Mixing uptime checks and
heartbeats on one page is the normal case, not an edge case.
Incidents
An incident is a title, an impact level, and a timeline of updates. Open one, then post updates as the situation develops:
| Field | Values |
|---|---|
| Impact | minor · major · critical |
| Status | investigating · identified · monitoring · resolved |
// Open it
POST /projects/:projectId/incidents
{ "title": "Elevated API latency", "impact": "major" }
// Narrate it
POST /projects/:projectId/incidents/:id/updates
{ "status": "identified", "message": "Root cause found, deploying a fix." }
// Close it - same endpoint, resolved status
POST /projects/:projectId/incidents/:id/updates
{ "status": "resolved", "message": "Fix deployed, latency back to normal." }
There's no separate "close" call - posting an update with status: resolved
is what closes it, and that update becomes the last line of the public timeline.
Alerting & cooldowns
Every notification sits behind a cooldown keyed to the thing that triggered it, so a crash loop firing ten thousand times still costs you one message. Each class has its own timer - a memory warning never suppresses a downed monitor.
| Trigger | When | Cooldown |
|---|---|---|
| New issue or regression | Immediately | Per user, project and error type - 4 hours |
| Spike detected | Immediately | Per issue - 24 hours |
| Legacy webhook delivery | Immediately | Per project and event type - 4 hours |
| Custom alert rule | Immediately | Set per rule, default 60 min - 0 disables it |
| Memory, CPU | On threshold, checked every 5 min | 15 minutes each |
| Slow endpoint | On threshold | 1 hour, per route |
| Slow DB query | On threshold | 30 minutes |
| Uptime monitor down / recovered | After 3 failed retries | Per monitor |
| Cron monitor down / recovered | Ping missing past interval + grace | Per monitor |
| SSL certificate expiring | ≤30 days left, checked every 5 min | Per monitor - 24 hours |
| Domain expiring | ≤30 days left, checked every 5 min | Per monitor - 24 hours, independent of the SSL cooldown |
| Project digest | Fridays at 12:00 | Weekly, to every member |
| Uptime report | Mondays at 14:00 | Weekly, to every member |
The legacy webhook is the simplest option and stays free forever: set a URL on Project → Settings and BugCatch posts to it on new issues, regressions, and spikes. Point it at a Slack incoming webhook URL and the payload is auto-detected and formatted for Slack. For anything more specific - custom alert rules replace it with rules you define, on Starter and above.
Monitor alerts ignore the per-member email setting
Downtime alerts go to every project member regardless of their notification preference, on the assumption that an unreachable production URL is everyone's problem.
Alert rules & notification channels
Starter and above replace "we email you when we decide" with rules you own. A rule says when to alert; a channel says where. They are separate so one Slack channel can serve every rule in the project, and changing where an alert goes never touches what triggers it.
Triggers
| Trigger | Fires when |
|---|---|
new_issue | A fingerprint is seen for the first time. |
issue_regression | A resolved issue reopens. |
spike_detected | An issue's rate jumps sharply against its own baseline. |
event_frequency | An issue crosses threshold events inside window_minutes. |
unique_users | An issue crosses threshold distinct affected users inside the window. |
monitor_down / monitor_recovered | An uptime monitor changes state. |
event_frequency and unique_users are questions about a time
range rather than something noticed at the moment it happens, so they're evaluated on a
one-minute cron rather than inline - expect up to a minute of lag versus the other
triggers, which fire immediately.
Filters
Narrow a trigger by levels, environments, releases,
tags, and titleContains. Every filter you set must match; one
you leave empty is not a constraint. Level, environment, and release are read from the
event, not the issue, so a rule scoped to production does
not fire when the same issue shows up in staging.
{
"name": "Prod fatals to on-call",
"triggerType": "new_issue",
"filters": { "levels": ["fatal"], "environments": ["production"] },
"channelIds": ["..."],
"cooldownMinutes": 30
}
Leave channelIds empty and the rule notifies every enabled channel in the
project. Test a rule against sample data without waiting for a real trigger or burning its
cooldown from Project → Alert Rules → Test.
Channels
| Type | Notes |
|---|---|
email | No recipients configured fans out to every project member who hasn't muted the project. |
slack | Formatted attachment with severity colour and a link back to the issue. |
discord | Formatted embed. |
telegram | Bot token plus chat id. |
ms_teams | Adaptive card. |
webhook | Raw JSON. Signed as X-BugCatch-Signature: sha256=<hmac> when a secret is set. |
pagerduty | Uses the issue or monitor id as dedup_key, so repeats fold into one incident. |
Every channel renders from the same alert payload, so adding a Discord channel never touches the logic that decides when to fire. Secrets stored in a channel's config - webhook signing key, bot token, PagerDuty routing key - are masked on every read; the API never hands one back once it's set. Send a sample notification to any channel to confirm it's wired correctly, independent of any rule.
Cooldowns are per issue, not per rule
If two different rules both react to the same issue, they still only alert once per cooldown window for that issue - a noisy issue can't silence an unrelated one, but it also can't double up your phone with two pages for the same crash.
Structured logs
A log line is not an issue. There's no fingerprint and no grouping - logs are a stream you search and tail, batched in from your console, Pino, or Winston transport.
POST /ingest/:projectId/logs
Same fire-and-forget shape as event ingest: authenticate with the SDK key, send up to 100 lines in a batch, get an immediate response. The write happens on a worker, off the request path.
| Field | Notes |
|---|---|
level | trace, debug, info, warn, error, or fatal. |
message | Full-text indexed - what search queries against. |
traceId | Links every line from the same request together. |
attributes | Arbitrary structured JSON. |
Searching and tailing
| Endpoint | Use |
|---|---|
GET /logs | Paginated, filter by level, search, traceId, since. |
GET /logs/trace/:traceId | Every line sharing a trace id, oldest first - jump from one log line to the whole request. |
GET /logs/stream | Live tail over Server-Sent Events, one line per message. |
Search is full-text, not substring
search runs a MySQL MATCH ... AGAINST natural-language query.
It's built for "find the request that logged X," not for matching an arbitrary
substring, and it ignores words shorter than MySQL's indexed minimum.
Retention piggybacks on your plan's event retention rather than a separate setting - logs
age out on the same cutoff as events. The live tail can't authenticate with the browser's
native EventSource API (it can't send an Authorization header),
so if you're building your own client against /logs/stream, open it with
fetch() and parse the SSE frames from the response body yourself.
Releases & deploy tracking
A release is a version string - 1.4.0, a git SHA - with optional commit,
repo, and author metadata. It's free on every plan, Free included: it's metadata, not a
volume the platform pays to store.
POST /projects/:projectId/releases/:version/deploys
Call this from CI on every deploy. If the release doesn't exist yet, this call creates it - registering a release by hand first is optional, because deploying is the common case and a separate registration step would just get skipped. A release can deploy to more than one environment; each deploy call adds a row rather than overwriting the last one.
What it gives an issue
| Field | Set |
|---|---|
firstRelease | Once, when the issue is first created. |
lastRelease | On every event, so it never goes stale. |
regressedInRelease | Only when a resolved issue reopens - the version that broke it again. |
All three come from the release you already set on init(), read
off the event - not from anything you configure separately.
GET /analytics/deploy-markers returns every deploy timestamp in range across
all releases, meant to render as vertical lines on your trend charts - a spike that starts
right after a deploy line is not a coincidence you have to notice yourself.
Plans & limits
Every plan gets the full issue pipeline. What changes is the ceilings and the monitoring built on top. Yearly billing is ten months for twelve.
| Limit | Free | Starter | Pro | Business |
|---|---|---|---|---|
| Monthly price | €0 | €4 | €10 | €20 |
| Yearly price | €0 | €40 | €100 | €200 |
| Projects | 1 | 3 | 10 | Unlimited |
| Events per month | 5,000 | 50,000 | 500,000 | 2,500,000 |
| Ingest rate limit | 60/min | 300/min | 1,000/min | 5,000/min |
| Event retention | 7 days | 30 days | 90 days | 365 days |
| Members per project | 1 | 3 | 10 | Unlimited |
| Monitors (uptime + cron, shared) | - | 3 | 20 | Unlimited |
| Source map releases | - | 3 | Unlimited | Unlimited |
| Custom alert rules | - | 5 | 25 | Unlimited |
| Notification channels | - | 3 | 10 | Unlimited |
Features by plan
| Feature | Free | Starter | Pro | Business |
|---|---|---|---|---|
| Source maps | - | Yes | Yes | Yes |
| Webhooks | - | Yes | Yes | Yes |
| Uptime monitors | - | Yes | Yes | Yes |
| Cron monitors (heartbeats) | - | Yes | Yes | Yes |
| Public status pages | - | Yes | Yes | Yes |
| Spike detection | - | Yes | Yes | Yes |
| Custom alert rules | - | Yes | Yes | Yes |
| Structured logs | - | Yes | Yes | Yes |
| Weekly digest | - | Yes | Yes | Yes |
| Issue collaboration | - | Yes | Yes | Yes |
| Server metrics | - | - | Yes | Yes |
| Distributed tracing | - | - | Yes | Yes |
| Custom dashboards | - | - | Yes | Yes |
| Data export | - | - | Yes | Yes |
| API access | - | - | Yes | Yes |
| SSO | - | - | - | Yes |
Releases and deploy tracking are not on this table - registering a release and seeing which one an issue first appeared in is free on every plan, Free included. It is metadata, not a volume the platform pays to store.
Limits follow the project owner, not you
Entitlements are resolved from whoever owns the project. A collaborator on a Free plan working inside a Pro project gets Pro features there, because the owner pays for them. There is no separate organisation or team billing - one subscription per user.
Paid plans begin with a 14-day Pro trial and no card. Billing runs through Paddle as merchant of record, so VAT and sales tax are calculated and remitted at checkout. Card changes, invoices, and cancellation all live in the Paddle portal, reachable from your billing settings.
Quotas & enforcement
Two different ceilings apply to ingest, and they fail differently on purpose.
| Ceiling | Scope | Response |
|---|---|---|
| Ingest rate limit | Per project, sliding 60-second window | 429 |
| Monthly event quota | Per owner, per billing period | 429 |
| Plan feature or resource limit | Dashboard and API actions | 402 with requiredPlan |
Why ingest returns 429 and not 402
A 429 is backpressure every HTTP client already understands, so SDKs back
off and retry rather than treating a spent quota as a permanent failure. Payment-related
rejections are only ever returned on interactive endpoints, never on the hot path your
app calls during a crash.
You are emailed once when the project passes 80% of the monthly quota, and again if it is exceeded - one message per severity per billing period, so a runaway release cannot flood your inbox. Usage for the current period, and monthly history, are both on the billing page.
Retention is enforced by deletion
Events older than your plan's retention window are purged permanently by a cleanup job - 7 days on Free, 365 on Business. Issues and their counters survive; the individual event bodies, stack traces, and breadcrumbs do not. Export anything you need to keep before it ages out.
Ingest format
If no SDK covers your language, post directly. The endpoint authenticates with the SDK key in the query string and always answers immediately - processing happens on a worker queue, so a slow pipeline never becomes your latency.
POST /ingest/:projectId?key={sdkKey}
{
"event_id": "aaaabbbb-0000-0000-0000-000000000001",
"timestamp": "2026-01-01T12:00:00Z",
"level": "error",
"message": "TypeError: Cannot read property x of undefined",
"exception": {
"values": [{
"type": "TypeError",
"value": "Cannot read property x of undefined",
"stacktrace": {
"frames": [{
"filename": "app.js",
"lineno": 42,
"in_app": true
}]
}
}]
}
}
| Field | Notes |
|---|---|
event_id | UUID you generate. Makes retries idempotent. |
timestamp | ISO 8601, when the error happened on the client. |
level | fatal, error, warning, or info. |
exception.values[] | An array so cause chains can be sent in order. |
in_app | Marks your code versus vendor frames. Drives which frame is highlighted. |
A 200 means the event was accepted onto the queue, not that it has been
processed. Expect roughly a second before it is visible.
REST API
Everything the dashboard does is available over HTTP. All endpoints below need a JWT in an
Authorization: Bearer header - access tokens last 15 minutes, refresh tokens 7
days. Interactive docs live at /api.
Auth
| POST | /auth/register | Create an account, returns tokens. |
| POST | /auth/login | Returns access and refresh tokens. |
| POST | /auth/refresh | Exchange a refresh token for a new access token. |
Projects
| POST | /projects | Create a project, returns its DSN. |
| GET | /projects | List your projects. |
| GET | /projects/:id | One project, including the DSN. |
| DELETE | /projects/:id | Soft-delete. Events are preserved. |
| POST | /projects/:id/rotate-key | New SDK key and DSN. The old key stops immediately. |
Issues
| GET | /projects/:projectId/issues | Paginated and filterable. |
| GET | /projects/:projectId/issues/stats | Counts by status. |
| PUT | /projects/:projectId/issues/bulk | Bulk resolve or ignore. |
| GET | /projects/:projectId/issues/:id | Issue detail. |
| PATCH | /projects/:projectId/issues/:id | Update status. |
List filters: status, level, search,
since, page, limit.
Events
| GET | /issues/:issueId/events | Paginated events for an issue. |
| GET | /issues/:issueId/events/histogram?days=7 | Daily frequency. |
| GET | /issues/:issueId/events/tags | Tag value aggregation. |
| GET | /issues/:issueId/events/:id | Raw event detail. |
Source maps
| POST | /projects/:projectId/source-maps | Upload a map for a release. |
| GET | /projects/:projectId/source-maps | List uploaded maps. |
| DELETE | /projects/:projectId/source-maps/:id | Delete a map. |
Account
| GET | /user/profile | Current user. |
| PATCH | /user/profile | Update name or password. |
| POST | /user/rotate-api-key | Rotate your personal API key. |
Scrubbing data
beforeSend runs on the client before anything leaves the process. It is the
right place to remove data that should never reach a third party, because a field you strip
here is never transmitted at all.
BugCatch.init({
dsn: '...',
beforeSend(event) {
// Drop a noisy class of error entirely
if (event.exception?.values?.[0]?.type === 'NetworkError') return false;
// Remove PII before it is sent
if (event.user) delete event.user.email;
return event;
},
});
Returning false discards the event. Returning the object sends the modified
version. For patterns you know up front, ignoreErrors and
ignoreUrls are cheaper - they never build the event in the first place.
Check what you are already sending
URLs are captured with their query strings, and those routinely carry tokens or email addresses. Breadcrumbs record clicked element text. Neither is scrubbed for you - decide what is acceptable and strip the rest here.
Ready?
Create a project, copy the DSN, and ship the SDK in your next deploy.