Migrating from Bugsnag or legacy clients
For agents: nothing to call — these are ingest endpoints, not API routes.
list_keys(org="acme", project="checkout-api")gives the key both of them authenticate with.
The slowest part of leaving an error tracker is rarely the dashboard. It is the fleet of services already posting somewhere else, each with its own deploy, its own owner, and its own reason not to be touched this quarter.
So both of the endpoints below exist for one reason: changing a URL should be the whole migration.
Bugsnag notifiers
Point your notifier's endpoint at Bugwatch and set its API key to your Bugwatch public key. Nothing else changes — not the notifier, not its version, not a line of your code.
Bugsnag.start({
apiKey: 'YOUR_BUGWATCH_PUBLIC_KEY',
endpoints: {
notify: 'https://ingest.bugwatch.io/notify',
sessions: 'https://ingest.bugwatch.io/notify',
},
})
The public key is the same one in your DSN — the part before the @. A disabled key is disabled here too.
What maps onto what
Bugsnag events become ordinary Bugwatch events, so grouping, scrubbing, filters, alert rules and issue assignment all work on them with nothing special configured.
| Bugsnag | Becomes | Note |
|---|---|---|
exceptions[].errorClass / message |
The exception type and value | Older notifiers send errorMessage; both are read |
exceptions[].stacktrace |
The stack trace | Reversed — see below |
stacktrace[].inProject |
in_app |
Drives which frames are yours |
severity |
Level | error, warning, info. An unhandled error stays error |
unhandled |
handled on the mechanism |
Absent when the notifier did not say |
groupingHash |
The issue fingerprint | Your existing merges survive the move |
app.version / app.releaseStage |
Release / environment | |
context |
Transaction | The route or job name |
user |
User | name becomes the username |
metaData |
Extra | Scrubbed like any other custom data |
breadcrumbs |
Breadcrumbs | |
device.osName / osVersion |
The OS context |
The stack is reversed on purpose. Bugsnag lists the throwing frame first and we list it last. Grouping reads the last in-app frame as the culprit, so a stack passed through unreversed would fingerprint every issue on its entry point — and an entire application's errors would arrive as one issue. If you are comparing a Bugsnag issue with ours side by side, this is why the frames look upside down.
metaData becomes extra data, never tags. Tags are a low-cardinality dimension used for aggregation; a custom field with a customer id in it would make them useless. Extra data is shown on the event and scrubbed by your project's rules.
Running both at once
Nothing stops you sending to Bugsnag and Bugwatch in parallel while you compare them — two notifier instances, two keys. Issue counts will not match exactly, because the two products group differently; what should match is which problems appear.
Pre-envelope Sentry clients
Older Sentry SDKs POST a bare event JSON to /api/{project}/store/ rather than an envelope. That endpoint is supported with the same DSN authentication as the envelope route, so a client too old to upgrade still reports.
POST https://ingest.bugwatch.io/api/42/store/
X-Sentry-Auth: Sentry sentry_key=YOUR_PUBLIC_KEY, sentry_version=7
The event's own event_id is honoured when it carries one, so a retried POST lands on the same event rather than creating a second issue. A body with "type": "transaction" is treated as a transaction, exactly as it would be inside an envelope.
Current SDKs should use /api/{project}/envelope/ — it carries sessions, attachments and client reports, which the legacy endpoint has no way to express.
Running both trackers at once (dual-send)
Before switching for good, send every event to both: the tracker you are on keeps working exactly as it does, and Bugwatch receives the same events so you can compare them on your own traffic. For JavaScript and Node, dualSend wraps the SDK's own transport:
import * as Sentry from "@sentry/node"; // or @sentry/browser
import { dualSend } from "@bugwatch/node"; // or @bugwatch/browser
Sentry.init({
dsn: process.env.OLD_DSN, // unchanged
transport: dualSend(Sentry.makeNodeTransport, process.env.BUGWATCH_DSN),
});
In the browser it is dualSend(Sentry.makeFetchTransport, BUGWATCH_DSN). When you have decided, remove the wrapper and point dsn at Bugwatch, or swap the two DSNs to keep the old tracker as the mirror for a while.
The mirror never costs the tracker you are on anything.
- It is a separate transport, with its own queue and its own rate limits. A mirror that is slow, down, or answering 429 never delays or drops an event bound for your current tracker, and its answer is never the one the SDK sees.
- Its drops are not reported through the SDK. A rate-limited mirror would otherwise send your current tracker a client report saying events were dropped that it never saw dropped.
- Client reports stay with your current tracker, because they describe its drops.
flush()gives both a chance to drain and returns the current tracker's answer.
This is checked on every release with the real SDK. One test runs it against two servers, the mirror starting with a 429. The end-to-end run sends an event from an app still on its old tracker and requires it to arrive both there and as an issue here.
Issue counts will not match exactly, because the two products group differently. What should match is which problems appear. Events count against your Bugwatch quota as usual while you mirror.
Other SDKs: most accept a custom transport. Send each envelope, unchanged, to the second DSN's envelope endpoint as well: {scheme}://{host}/api/{project}/envelope/?sentry_key={key}&sentry_version=7, with any path before the project id kept. Never let the second send block or fail the first.
Bringing your issue backlog with you
Changing where events go is the easy half. The hard half is the backlog you have already triaged — the issues somebody resolved, muted, or assigned — because arriving on a new tracker with all of that reset means re-triaging a year of decisions in week one.
The importer carries triage, not history. Events are not copied: an issue arrives with its status, its assignee, its first and last seen dates, and its counts recorded as what the old tracker said. Nothing pretends the events came too.
Running it
In the dashboard: Settings → General → Import issues, for owners and admins. Choose the export file and press Preview: every page is checked as a dry run and you get one report for the whole file. Import is enabled only once you have read it. The file is read in your browser and sent page by page; nothing about the old tracker is stored but the issues themselves. Set Imported from once and keep it: it is part of how a second run recognises issues it already brought over.
The file can be a JSON array of issues, an object with an issues array, or one JSON value per line, which is what you get by saving every page of an issue-list API one after another. Each issue's id, title, culprit, status, level, firstSeen, lastSeen, count, userCount, permalink and its assignee (an assignee string, or an assignedTo object with an email) are read. A row that is not an issue, has no id, or repeats an issue already in the file is named in the summary rather than dropped.
Exporting from the old tracker happens on your machine, with your own token. Most trackers' issue-list endpoint answers one JSON page at a time and points to the next page in a Link header. This saves every page, one per line:
url="https://old-tracker.example/api/0/projects/ORG/PROJECT/issues/?limit=100"
: > issues.ndjson
while [ -n "$url" ]; do
curl -sS -D headers.txt -H "Authorization: Bearer $OLD_TOKEN" "$url" >> issues.ndjson
echo >> issues.ndjson
url=$(grep -i '^link:' headers.txt | grep -o '<[^>]*>; rel="next"; results="true"' | sed 's/^<\([^>]*\)>.*/\1/')
done
For agents:
import_issues(org="acme", project="checkout-api", source="sentry", issues=[…]). It defaults to a dry run — call it once to read the coverage report, then again withdryRun=false.
You export the issues; Bugwatch never asks for a token to your old tracker. Whatever the old product gives you — its API, its CSV export, a script you already have — the issues arrive in the request body. Nothing here holds a credential to a system you are leaving.
curl -X POST https://api.bugwatch.io/v1/orgs/acme/projects/checkout-api/import-issues \
-H "Authorization: Bearer $BUGWATCH_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"source": "sentry",
"issues": [
{"id": "4512", "title": "TypeError: x is not a function", "status": "resolved",
"assignee": "dana@acme.example", "firstSeen": "2025-11-02T09:14:00Z",
"count": 1204, "fingerprint": "8f2c…", "permalink": "https://…/issues/4512"}
]
}'
id is the only required field per issue; title, culprit, status, level, firstSeen, lastSeen, count, userCount, assignee, fingerprint and permalink are used when present. Up to 500 issues per call — page through a larger backlog, and re-running a page is safe (see below). Requires the event:write scope.
dryRun defaults to true. The first call changes nothing and returns the same coverage report the real run would, so you can read what will happen — how many merge, how many get synthetic fingerprints, which assignees do not match — before anything is written. Set "dryRun": false to commit.
An issue that already exists here is never overwritten. If the fingerprint already has an issue in this project, that issue was made by your own events and whatever triage it carries was decided in this product; letting an import stamp over it would undo a decision made here in favour of one made elsewhere. Those are reported as already known rather than as errors, which is also why re-running a page is safe.
What survives, and what does not
| Carried | Not carried |
|---|---|
| Status — resolved, ignored, unresolved | The events themselves |
| Assignee, when the email matches a member here | An assignee who is not a member |
| First seen, last seen | Comments and activity |
| Event and user counts, as source metadata | Live counters (see below) |
| A link back to the original issue |
Counts are metadata, not counters. Exact counts here come from the pipeline that produced them, and an imported number is a fact about somewhere else. It is shown as "1,204 events at the old tracker" and never added to this issue's own count, which starts where your events start.
Whether an imported issue merges with new events
This is the question worth understanding before you run it.
An imported issue merges when its fingerprint is the fingerprint your SDK sends: the values your code sets with scope.setFingerprint([...]) (or event.fingerprint in beforeSend). The issue is stored under exactly the hash a live event carrying that fingerprint is grouped by, so the next time that error happens here it lands on the imported issue, and your triage keeps applying. This is verified end to end on every release: an issue is imported, a real SDK sends an error with its fingerprint, and the event must be stored under the imported issue with no second issue opened.
Do not pass the old tracker's own grouping hashes. A tracker computes those with its own algorithm from the stack trace, and this product groups with its own; the two never produce the same value, so an issue imported under one would be reported as mergeable and never merge. Leave fingerprint out unless your code sets it.
Without one, the key is invented, and a live occurrence of the same error opens a separate issue beside the imported one. The importer counts these and tells you how many: they are labelled synthetic in the coverage report. A high number is normal for a codebase that relies on default grouping, and it is a limit of what can be carried, not a fault. Know it before you look at your issue list and conclude the import duplicated everything.
Fingerprints are never derived from the issue title. Titles get localised, truncated and edited; two trackers agreeing on one is luck, and an issue merged by luck is one nobody can explain later.
The coverage report
Every import ends with a count and a denominator, plus a line per issue that did not come across cleanly:
4,180 of 4,200 imported · 20 skipped · 63 synthetic fingerprints
issue:9931 duplicate_fingerprint same grouping key as issue:8812; skipped
issue:9944 assignee_not_matched "dana@old.example" is not a member; imported unassigned
issue:9951 unknown_status status "review" is not recognised; imported as unresolved
An unrecognised status always becomes unresolved, never resolved. Burying an open issue on import is triage destroyed rather than carried, and it is the one failure that would be invisible.
What to check after switching
- Events arrive. The issue list is the fastest confirmation; it updates within seconds.
- The release and environment look right. These come from
app.versionandapp.releaseStage, which not every notifier is configured to send. - Your quota. Bugwatch applies your organization's quota to these endpoints exactly as it does to the envelope route, so a migration that doubles your volume shows up as back-pressure rather than as a surprise on the invoice.
- Your inbound filters. Deny rules for user agents, origins and releases apply here too.
See also
- SDKs & platforms overview — the DSN format and the platform table
- Filters and scrubbing — what never leaves your account