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.

  1. 01

    Create a project

    One project per application. Creating it returns a DSN - the URL your SDK posts to.

  2. 02

    Install the SDK

    npm install bugcatch-sdk
  3. 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.

src/main.tsx
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:

DSN
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:

anywhere in your app
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.

FingerprintWhat 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.

LevelUse for
fatalThe process or app died.
errorAn operation failed. The default for uncaught exceptions.
warningDegraded but recovered - a retry succeeded, a fallback kicked in.
infoNotable but not a problem.

Statuses

Where an issue sits in triage. You set these from the dashboard.

StatusMeaning
unresolvedOpen. The default for anything new.
resolvedFixed. Reopens automatically as a regression if the fingerprint returns.
ignoredKnown and accepted. Stays quiet - permanently, or until a snooze condition you set expires. See below.
mergedAn 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.

FieldUn-ignores when
ignoreDurationMinutesThat much time has passed.
ignoreEventCountThe 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.

FilterDrops when
messagePatternsThe 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.
allowedDomainsThe 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.
filterLegacyBrowsersThe User-Agent is a known legacy signature - old IE, old Android WebView, old iOS Safari.
filterLocalhostThe 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.

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.

FieldNotes
releaseVersionMust match the release your SDK sends, exactly.
fileNameThe bundle the map belongs to, e.g. index-a1b2c3.js.
Map fileThe .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

OptionTypeDefaultDescription
dsnstringrequiredProject DSN.
releasestring-App version, e.g. 1.2.3.
environmentstring-production, staging, …
debugbooleanfalsePrint SDK logs to the console.
maxBreadcrumbsnumber100Breadcrumbs kept in memory.
autoCaptureErrorsbooleantrueAttach global error handlers.
autoCaptureBreadcrumbsbooleantrueRecord 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:

ErrorBoundary.tsx
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.

server.ts
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
index.jsbefore every other import
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:

OptionTypeDefaultDescription
autoTrackRequestsbooleanfalseIntercept 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.

pom.xml
<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'

Application.java
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
OptionDefaultDescription
dsnrequiredSame DSN used for error capture.
reportInterval30000How often a snapshot is sent, in ms.
instanceIdos.hostname()Which instance a snapshot came from. Set it when several pods sit behind a load balancer.
debugfalsePrint 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.

EndpointReturns
GET /tracing/stats?hours=24Per-route p50 / p75 / p95 / p99, throughput, error rate.
GET /tracing/transactionsPaginated list - filter by name, op, environment, since.
GET /tracing/transactions/:idOne 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.

BehaviourValue
Check intervalEvery 5 minutes
Request timeout10 seconds
Retries before marking down3, spaced 5 seconds apart
Uptime windowRolling 24 hours
Statusesup · 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.

EndpointUse
POST/GET /cron/:id/pingReport success. This is the only call most jobs need.
POST /cron/:id/startLog that a run started, without changing up/down status - useful for long jobs where you also want to see run duration.
POST /cron/:id/failReport failure explicitly, instead of waiting for the next ping to simply not arrive.
SettingMeaning
intervalMinutesExpected time between pings - how often the job is supposed to run.
graceMinutesExtra 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:

FieldValues
Impactminor · major · critical
Statusinvestigating · 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.

TriggerWhenCooldown
New issue or regressionImmediatelyPer user, project and error type - 4 hours
Spike detectedImmediatelyPer issue - 24 hours
Legacy webhook deliveryImmediatelyPer project and event type - 4 hours
Custom alert ruleImmediatelySet per rule, default 60 min - 0 disables it
Memory, CPUOn threshold, checked every 5 min15 minutes each
Slow endpointOn threshold1 hour, per route
Slow DB queryOn threshold30 minutes
Uptime monitor down / recoveredAfter 3 failed retriesPer monitor
Cron monitor down / recoveredPing missing past interval + gracePer monitor
SSL certificate expiring≤30 days left, checked every 5 minPer monitor - 24 hours
Domain expiring≤30 days left, checked every 5 minPer monitor - 24 hours, independent of the SSL cooldown
Project digestFridays at 12:00Weekly, to every member
Uptime reportMondays at 14:00Weekly, 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

TriggerFires when
new_issueA fingerprint is seen for the first time.
issue_regressionA resolved issue reopens.
spike_detectedAn issue's rate jumps sharply against its own baseline.
event_frequencyAn issue crosses threshold events inside window_minutes.
unique_usersAn issue crosses threshold distinct affected users inside the window.
monitor_down / monitor_recoveredAn 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

TypeNotes
emailNo recipients configured fans out to every project member who hasn't muted the project.
slackFormatted attachment with severity colour and a link back to the issue.
discordFormatted embed.
telegramBot token plus chat id.
ms_teamsAdaptive card.
webhookRaw JSON. Signed as X-BugCatch-Signature: sha256=<hmac> when a secret is set.
pagerdutyUses 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.

FieldNotes
leveltrace, debug, info, warn, error, or fatal.
messageFull-text indexed - what search queries against.
traceIdLinks every line from the same request together.
attributesArbitrary structured JSON.

Searching and tailing

EndpointUse
GET /logsPaginated, filter by level, search, traceId, since.
GET /logs/trace/:traceIdEvery line sharing a trace id, oldest first - jump from one log line to the whole request.
GET /logs/streamLive 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

FieldSet
firstReleaseOnce, when the issue is first created.
lastReleaseOn every event, so it never goes stale.
regressedInReleaseOnly 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.

LimitFreeStarterProBusiness
Monthly price€0€4€10€20
Yearly price€0€40€100€200
Projects1310Unlimited
Events per month5,00050,000500,0002,500,000
Ingest rate limit60/min300/min1,000/min5,000/min
Event retention7 days30 days90 days365 days
Members per project1310Unlimited
Monitors (uptime + cron, shared)-320Unlimited
Source map releases-3UnlimitedUnlimited
Custom alert rules-525Unlimited
Notification channels-310Unlimited

Features by plan

FeatureFreeStarterProBusiness
Source maps-YesYesYes
Webhooks-YesYesYes
Uptime monitors-YesYesYes
Cron monitors (heartbeats)-YesYesYes
Public status pages-YesYesYes
Spike detection-YesYesYes
Custom alert rules-YesYesYes
Structured logs-YesYesYes
Weekly digest-YesYesYes
Issue collaboration-YesYesYes
Server metrics--YesYes
Distributed tracing--YesYes
Custom dashboards--YesYes
Data export--YesYes
API access--YesYes
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.

CeilingScopeResponse
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}

request body
{
  "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
        }]
      }
    }]
  }
}
FieldNotes
event_idUUID you generate. Makes retries idempotent.
timestampISO 8601, when the error happened on the client.
levelfatal, error, warning, or info.
exception.values[]An array so cause chains can be sent in order.
in_appMarks 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/registerCreate an account, returns tokens.
POST/auth/loginReturns access and refresh tokens.
POST/auth/refreshExchange a refresh token for a new access token.

Projects

POST/projectsCreate a project, returns its DSN.
GET/projectsList your projects.
GET/projects/:idOne project, including the DSN.
DELETE/projects/:idSoft-delete. Events are preserved.
POST/projects/:id/rotate-keyNew SDK key and DSN. The old key stops immediately.

Issues

GET/projects/:projectId/issuesPaginated and filterable.
GET/projects/:projectId/issues/statsCounts by status.
PUT/projects/:projectId/issues/bulkBulk resolve or ignore.
GET/projects/:projectId/issues/:idIssue detail.
PATCH/projects/:projectId/issues/:idUpdate status.

List filters: status, level, search, since, page, limit.

Events

GET/issues/:issueId/eventsPaginated events for an issue.
GET/issues/:issueId/events/histogram?days=7Daily frequency.
GET/issues/:issueId/events/tagsTag value aggregation.
GET/issues/:issueId/events/:idRaw event detail.

Source maps

POST/projects/:projectId/source-mapsUpload a map for a release.
GET/projects/:projectId/source-mapsList uploaded maps.
DELETE/projects/:projectId/source-maps/:idDelete a map.

Account

GET/user/profileCurrent user.
PATCH/user/profileUpdate name or password.
POST/user/rotate-api-keyRotate 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.