Home

BugTape docs

API Reference

Every public endpoint.

Base URL: https://app.bugtape.ai

All endpoints are prefixed with /v1/.

Authentication

The API uses these authentication modes:

ModeHeader(s)Used for
Bearer agent PATAuthorization: Bearer bt_pat_…Explicit agent actions; work and evidence require current project grants as stated below
nonePublic unauthenticated endpoints (waitlist, login, reset, etc.)
X-BugTape-KeyX-BugTape-Key: bt_live_*SDK ingestion (POST /v1/ingest, POST /v1/ingest/presence, POST /v1/ingest/ai-summary)
Bearer JWTAuthorization: Bearer <token>User-scoped endpoints that do not need an org (e.g. /v1/auth/me)
Bearer JWT + X-BugTape-OrgAuthorization: Bearer <token> + X-BugTape-Org: <uuid>All workspace/management endpoints
Bearer JWT + SuperadminAuthorization: Bearer <token>Endpoints under /v1/admin/* — the caller's email must be in the ADMIN_EMAILS env allowlist

JWT access tokens last one hour; refresh tokens last30days. Human requests check the current active account, email and session revision. Status, email and password changes advance the revision and retire previously issued access/refresh tokens; reactivation requires a new login. Profile edits do not retire sessions. These guarantees start with migration048. Version2 tokens with no revision represent revision0. A rejected session returns401; an unavailable account lookup returns503 AUTH_UNAVAILABLE so clients can retry. Agent PATs keep their separate revocation/expiry lifecycle.

Exchange a refresh token via POST /v1/auth/refresh for a fresh access token.

See Authentication for details on token lifetimes and key formats.

Agent token management

Use a human JWT and X-BugTape-Org. GET /v1/org/agent-pats lists token metadata, project grants and an opaque accessRevision; it never returns secrets. POST /v1/org/agent-pats creates a token and returns its secret once. PATCH /v1/org/agent-pats/:id changes scopes, expiresAt and/or projectGrants. Send the latest accessRevision as expectedRevision to reject concurrent edits. A mismatch returns 409 AGENT_ACCESS_CHANGED; reload, review and resubmit. Legacy callers may omit this field, but then have no stale-edit protection.

Only admins/managers can edit access; a manager cannot manage an admin-owned token. An expired token returns 409 AGENT_PAT_EXPIRED and requires replacement. Revoked tokens cannot be edited. Expiry must be in the future within 365 days. A supplied projectGrants array replaces the selected work grants; an empty array revokes all work grants. Explicit grant expiry cannot exceed token expiry. Omitting projectGrants preserves existing grants, capped by any shorter token expiry. Project grants affect work claims/evidence/verification/symbol publishing; they do not narrow legacy workspace actions. DELETE /v1/org/agent-pats/:id revokes the credential.

Agent claims and runner verification

These endpoints use a current agent PAT, not a capture key or human JWT. They recheck the active account, workspace membership, token scopes, project grant, canonical issue and lease. The complete request fields and signing byte format are in Runner verification. No private server import is required.

All paths below start with /v1/agent-work. POST bodies are strict JSON objects. Every work command has UUID commandId, projectId and issueId. Lease commands also require claimId, generation and fencingToken; the latter two are positive decimal strings. Preserve the exact command after an uncertain response and retry it with the same ID. Changing input under that ID is a conflict.

Method and pathRequired PAT actionAdditional input / behavior
POST /claimsclaim_issueOptional ttlSeconds:60–900, default300. Returns the current fenced lease.
POST /claims/renewclaim_issueLease fields; optional TTL. Expired or stale leases cannot be renewed.
POST /claims/releaseclaim_issueLease fields. Release unfinished work.
POST /claims/completeclaim_issueLease fields plus receiptId. Closes work; does not verify the issue.
GET /claimsclaim_issue or request_evidenceQuery projectId, issueId; returns {claim}.
POST /verification/challengessubmit_verificationLease fields. Same PAT must submit the signed attempt.
POST /verification/receiptssubmit_verificationExact measured submission and optional runner proof. Unsigned attempts remain pending.
GET /verification/receipts/:receiptIdread_verificationQuery projectId, issueId; inspect status, trust and current applicability.
GET /verification/receipts/:receiptId/evidencerequest_evidenceSame query; metadata only, contentAvailable:false.
POST /verification/promotesubmit_verification plus current claim authorityLive lease fields, exact target builds and current receipt IDs covering observed applications/runtimes; successful promotion also completes the claim.

A valid signature is not sufficient for promotion. The server also checks registered scope, actual ready artifacts, test outcomes and current authority. A receipt can remain historical after recurrence, regrouping or revoked evidence. Legacy verified_fixed labels can be unproven. GET /v1/bugs/:id includes nullable active_verification_promotion_id; use receipt reads to check current applicability.

Human enrollment and measured evidence

Runner/device enrollment is human organization admin only, with Bearer JWT and X-BugTape-Org. Paths start with /v1/agent-work/verification:

Method and pathInput
POST /runnerscommandId, projectId, name, Ed25519 publicKeyPem, trustLevel (ci or device_lab), allowedRuntimes. Never send a private key.
POST /devicescommandId, projectId, name, runnerId, applicationId, installationId, runtime, deviceIdentifierSha256, osVersion; physical iOS requires explicit physicalDeviceConfirmed:true.
GET /enrollmentQuery projectId; public enrollment metadata only.
POST /runners/revokecommandId, projectId, runnerId.
POST /devices/revokecommandId, projectId, deviceId.

Register the tested build through the human manager/admin application registry: POST /v1/applications/projects/:projectId/:applicationId/builds with requestId, sourceRevision and artifactSha256. Copy the returned ID; never use a failed build's identity for repaired code.

Check GET /v1/evidence/capabilities before attempting evidence transfer. A PAT needs create_bug and a current project grant to reserve/upload a measured log; verification scope alone does not grant upload access. Reserve with POST /v1/evidence/projects/:projectId/artifacts, upload exact bytes with PUT /v1/evidence/projects/:projectId/artifacts/:artifactId/content, and inspect ready metadata with GET /v1/evidence/projects/:projectId/artifacts/:artifactId using get_repro_context and a grant. The guide lists required fields and hash checks. Use repaired application/build/installation IDs. Normally omit occurrenceId from the reservation; the failed occurrence has the failed build scope. The verification receipt links that failure separately through failedOccurrenceId.

The publish_symbols PAT action is separate from create_bug. Local symbol admission requires a current exact project grant plus an existing application/build scope. Capture keys, ordinary PATs and human sessions cannot use that upload path. Hosted symbol admission remains disabled until G9. A stored symbol artifact is not usable until the bounded worker records its own exact UUID and architecture match.

For a locally enabled environment, publish with:

BUGTAPE_PAT=... npm --workspace @bugtape/api run symbols:publish -- --endpoint https://host --project UUID --application UUID --build UUID --file PATH

The CLI checks advertised capability and the exact ready receipt. It never supplies UUID or architecture; the isolated worker observes and records those values.

Rate limits

ScopeLimitWindow
General API200 requests60 seconds per IP
Ingestion100 requests60 seconds per IP
Auth (login/register/reset)10 requests60 seconds per IP

Rate-limited responses return 429:

{
  "error": "Too many requests",
  "retryAfter": 45
}

Payload size limit

Maximum JSON body size: 10 MB.

Common error envelope

Every error response is JSON and always contains at least an error field. When Express 5's global error handler catches an unhandled exception it also attaches a requestId so you can correlate logs:

{
  "error": "Internal server error",
  "requestId": "req_abc123"
}

Malformed JSON bodies return:

{ "error": "Invalid JSON in request body" }

Plan-limit violations return HTTP 402:

{
  "error": "Plan limit reached",
  "limit": 100,
  "current": 100,
  "plan": "free",
  "upgradeUrl": "/console/#settings"
}

Health

Fly liveness probes GET /v1/health. That 200 is not a ship signal. Release checks use GET /v1/health/deep (see repo docs/reliability.md and DEPLOY_FLY.md). jobs.status === 'degraded' can still ride on HTTP 200; refuse ship unless the debt is the owned historical set.

There is no published OpenAPI document. @bugtape/contracts plus packages/api/tests/integration/contract-drift.test.ts are the handler-drift tripwire.

GET /v1/health

Verifies the API process and a cheap PostgreSQL ping. Fly uses this as liveness.

Auth: none

Success response (200):

{
  "status": "ok",
  "service": "bugtape-api",
  "timestamp": "2026-04-17T00:00:00.000Z"
}

Degraded (503):

{
  "status": "degraded",
  "service": "bugtape-api",
  "error": "Database unreachable",
  "timestamp": "2026-04-17T00:00:00.000Z"
}

GET /v1/health/deep

Unauthenticated dependency report: db, migrations, email, ai, jobs (pg-boss DLQ / failed-consumer depths), redis. Names, booleans and counts only — no secrets. HTTP 200 when db + migrations are ok; 503 when those critical checks fail. jobs and redis are informational on the HTTP status and still matter for release.

Auth: none


Auth

All auth endpoints live under /v1/auth/* and share the auth rate limiter (10 req/min/IP).

POST /v1/auth/register

Open self-serve signup. Creates a user, org, default project, and API key. Duplicate email returns 409. Waitlist remains as an optional overflow path.

Auth: none

Request body:

{
  "email": "user@example.com",
  "password": "at-least-8-chars",
  "name": "Full Name",
  "orgName": "Workspace Name"
}

Response (201): user, org, project, apiKey, accessToken, refreshToken.

Error codes: 400 Validation failed · 409 Email already registered.

POST /v1/auth/waitlist

Add an email to the waitlist. Always responds with 202 for new or in-flight pending rows and 200 for already-approved rows. Idempotent for rejected rows (re-promotes them to pending). Sends a "received" email asynchronously.

Auth: none

Request body:

{
  "email": "user@example.com",
  "name": "Full Name",
  "orgName": "Workspace Name",
  "useCase": "optional, up to 2000 chars"
}

Success responses:

{ "success": true, "status": "pending", "message": "You have been added to the BugTape waitlist." }
{ "success": true, "status": "approved", "message": "Your workspace is already approved. Check your email for the signup link." }

Error codes: 400 Validation failed · 409 Email already has an account · 409 Already completed signup.

GET /v1/auth/approval/:token

Validate a waitlist approval token without consuming it. Used by the signup form to pre-fill fields.

Auth: none

Path params: token — hex approval token from the email link.

Success (200):

{
  "email": "user@example.com",
  "name": "Full Name",
  "orgName": "Workspace Name"
}

Error codes: 400 Invalid or expired approval link.

POST /v1/auth/complete-signup

Consume a waitlist approval token and materialize the user, organization, default project, first API key, and tokens. Marks the new user as email-verified.

Auth: none

Request body:

{
  "token": "<approval token>",
  "name": "Full Name",
  "password": "at-least-8-chars"
}

Success (201):

{
  "user":   { "id": "uuid", "email": "user@example.com", "name": "Full Name" },
  "org":    { "id": "uuid", "name": "Workspace Name", "slug": "workspace-name" },
  "project":{ "id": "uuid" },
  "apiKey": "bt_live_...",
  "accessToken": "eyJ...",
  "refreshToken": "eyJ..."
}

Error codes: 400 Validation failed / Invalid or expired approval link · 409 Email already registered.

POST /v1/auth/login

Authenticate with email + password.

Auth: none

Request body:

{ "email": "user@example.com", "password": "..." }

Success (200):

{
  "user": { "id": "uuid", "email": "user@example.com", "name": "Full Name" },
  "org":  { "id": "uuid", "name": "Workspace Name" },
  "accessToken": "eyJ...",
  "refreshToken": "eyJ..."
}

Error codes:

StatusBodyCause
400{ "error": "Validation failed" }Missing/invalid email or password
401{ "error": "Invalid credentials" }Unknown email or wrong password
403{ "error": "Invitation pending — check your email to set a password.", "code": "INVITE_PENDING" }User record exists but has no password yet (invite flow)
403{ "error": "Verify your email before signing in", "code": "EMAIL_UNVERIFIED", "maskedEmail": "us**@example.com" }Email not verified

Invitation account setup

POST /v1/auth/invitations/inspect accepts {token} and returns {orgName,email,expiresAt} without consuming the token. POST /v1/auth/invitations/accept accepts {token,email,newPassword} and returns {success,orgId,orgName,message}. The password needs at least8characters. Both are public bearer-link operations. Invalid, expired, revoked or reused credentials return400 INVITATION_INVALID. Current membership is checked under lock. Acceptance does not overwrite an active account password.

The emailed link is /console/invite#token=…. The page removes the fragment from history and keeps it only in memory; after reload, reopen the email link. Authentication pages are excluded from console self-report recording.

POST /v1/auth/api-key-session is retired and always returns410 CAPTURE_KEY_SESSION_RETIRED. Public browser capture keys only authorize capture. Agents use scoped PATs; human auth routes, including switch-org, reject PATs. GET /v1/auth/agent-context returns only the PAT workspace {orgId,orgName,role,scopes} and rejects a mismatched workspace header. MCP get_current_org uses this scoped endpoint.

POST /v1/auth/refresh

Exchange a current version2 refresh token for a fresh access token + refresh token. Older sessions require one fresh sign-in following the capture-key session retirement. Refresh tokens cannot authenticate bearer routes.

Auth: none (the refresh token itself is the credential)

Request body:

{ "refreshToken": "eyJ..." }

Success (200):

{ "accessToken": "eyJ...", "refreshToken": "eyJ..." }

Error codes: 400 Refresh token required · 401 Invalid, expired or retired session, inactive/missing account, or malformed token · 503 Authentication lookup temporarily unavailable.

GET /v1/auth/me

Return the authenticated user, all org memberships, and a isSuperAdmin flag.

Auth: Bearer JWT

Success (200):

{
  "id": "uuid",
  "email": "user@example.com",
  "name": "Full Name",
  "avatar_url": null,
  "email_verified": true,
  "orgs": [
    { "orgId": "uuid", "role": "admin", "orgName": "Workspace Name" }
  ],
  "isSuperAdmin": false
}

Error codes: 401 Missing or invalid token · 404 User not found.

POST /v1/auth/forgot-password

Request a password-reset email. Always returns 200 to prevent email enumeration. Sends the reset link asynchronously; in non-production, the response also includes token for testing.

Auth: none

Request body:

{ "email": "user@example.com" }

Success (200):

{ "success": true, "message": "If that email exists, a reset link has been sent." }

Error codes: 400 Email is required.

POST /v1/auth/reset-password

Consume a password-reset token atomically and set a new password for an active account. Pending invitations and deactivated accounts cannot use recovery to activate themselves. New teammates use invitation acceptance.

Auth: none

Request body:

{
  "token": "<reset token>",
  "email": "user@example.com",
  "newPassword": "at-least-8-chars"
}

Success (200):

{ "success": true, "message": "Password has been reset. You can now sign in." }

Error codes: 400 Validation failed / Invalid or expired reset token.

POST /v1/auth/send-verification

Issue a fresh email-verification token for the currently authenticated user and send the verification email.

Auth: Bearer JWT

Request body: (none)

Success (200):

{ "success": true, "message": "Verification email sent", "maskedEmail": "us**@example.com" }

Or, if already verified:

{ "success": true, "message": "Email already verified" }

Error codes: 404 User not found · 503 Email delivery is not configured or failed. Contact support.

POST /v1/auth/resend-verification

Public variant of send-verification. Always returns 200 with a generic message, even for unknown / already-verified addresses, to prevent enumeration.

Auth: none

Request body:

{ "email": "user@example.com" }

Success (200):

{
  "success": true,
  "message": "If that account exists, a verification email has been sent.",
  "maskedEmail": "us**@example.com"
}

Error codes: 400 Validation failed · 503 Email delivery failed.

POST /v1/auth/verify-email

Consume an email-verification token and mark the owning user as verified.

Auth: none

Request body:

{ "token": "<verification token>" }

Success (200):

{ "success": true }

Error codes: 400 Token is required · 400 Invalid or expired verification token.

GET /v1/auth/whats-new

Summarize activity since the user's last_login for the currently-scoped org. Used by the "What's new" popover in the console.

Auth: Bearer JWT + X-BugTape-Org

Success (200):

{
  "items": [
    { "type": "new_bug", "id": "uuid", "title": "...", "severity": "high", "ts": "ISO 8601" },
    { "type": "regression", "id": "uuid", "title": "...", "severity": "critical", "ts": "ISO 8601" },
    { "type": "resolved", "id": "uuid", "title": "...", "severity": "low", "ts": "ISO 8601" }
  ],
  "since": "ISO 8601",
  "counts": { "newBugs": 3, "regressions": 1, "resolved": 5 }
}

If no org header is supplied, returns { "items": [] }. On first login (no prior last_login), returns { "items": [], "firstLogin": true }.


Ingestion

POST /v1/ingest

Submit a bug report. This is the primary SDK integration endpoint.

Auth: X-BugTape-Key Rate limit: 100/min per IP

Legacy screenshot/audio/encrypted fields are not proof of retrievable media storage. For current capture IDs, payload limits and storage boundaries, use the ingest contract.

Request headers

Content-Type: application/json
X-BugTape-Key: bt_live_your_key_here

Request body

{
  // Required
  title: string,                // 1-500 characters

  // Optional
  description?: string,         // 0-10,000 characters
  severity?: "critical" | "high" | "medium" | "low",  // default: "medium"
  url?: string,                 // 0-2,000 characters
  userAgent?: string,           // max 500 characters
  viewport?: string,            // max 50 characters, e.g. "1920x1080"
  events?: Event[],             // max 1,000 events
  screenshot?: any,             // base64 or JSON object
  audio?: any,                  // base64 or JSON object
  aiSummary?: any,              // client-side AI summary
  encrypted?: object,           // unsupported: not a retrievable encrypted-artifact path
  reporterEmail?: string,       // valid email
  reporterId?: string,          // max 200 chars — app-specific reporter id
  sessionId?: string,           // max 200 chars
  userId?: string,              // max 200 chars
  release?: string,             // max 200 chars (app version on mobile)
  environment?: string,         // max 100 chars
  platform?: string,            // web | ios | android | react-native | flutter | server | other (inferred from userAgent when omitted)
  device?: string,              // max 120 chars, e.g. "iPhone15,3" → metadata.device
  osVersion?: string,           // max 60 chars, e.g. "17.4.1" → metadata.osVersion
  metadata?: object             // arbitrary per-occurrence metadata
}

Event schema

{
  type: string,        // e.g. "error", "console:error", "network:fetch"
  timestamp: number,   // Unix milliseconds (default: now)
  data?: any           // Event-specific payload
}

Success response (201)

{
  "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "occurrenceId": "b2c3d4e5-f6a7-8901-bcde-f23456789012",
  "fingerprint": "abc123def456gh78",
  "deduplicated": false,
  "regression": false,
  "is_regression": false
}
FieldTypeDescription
idstringUUID — stable grouped-bug identifier.
occurrenceIdstringUUID — this specific occurrence under the grouped bug.
fingerprintstring16-character hex; reports with the same fingerprint are grouped.
deduplicatedbooleantrue if this report matched an existing open bug.
regressionbooleantrue if this report matched a previously resolved bug (reopened).
is_regressionbooleanAlias for regression.

Error responses

StatusBodyCause
400{ "error": "Validation failed", "details": [...] }Invalid payload
400{ "error": "Invalid JSON in request body" }Malformed JSON
401{ "error": "Missing or invalid API key" }Bad or missing X-BugTape-Key
402See plan-limit envelope aboveMonthly bug limit exceeded
429See rate-limit envelope aboveRate limited
500{ "error": "Internal server error", "requestId": "..." }Server error

Deduplication

Reports are fingerprinted using:

SHA256(errorType + ":" + normalizedMessage + ":" + urlPattern)
  • Error type: extracted from the first error event, or "manual" for user-submitted reports.
  • Message normalization: hex addresses, timestamps, UUIDs, IPs, port numbers, and large numbers are replaced with placeholders.
  • URL normalization: numeric IDs replaced with <id>, UUIDs with <uuid>.

If a report's fingerprint matches an existing open bug, it increments the report count (deduplicated: true). If it matches a resolved bug, the bug is reopened (regression: true).

Signal classification

Each ingest also classifies the payload as issue, watch, or diagnostic (e.g. benign SDK probes) via a heuristic pipeline over event types, auto-report kind, titles, and network status. Diagnostics are hidden from the bug inbox by default; pass ?includeDiagnostics=true on list/live endpoints to see them.

Filtering list endpoints

GET /v1/bugs and GET /v1/bugs/groups accept, besides projectId, status, severity, signalClass and search:

  • platform=web|ios|android|react-native|flutter|server|other — keep bugs with any occurrence captured on that platform (the same failure on web and iOS is one bug with platforms: ["ios","web"]). Invalid values are 400.
  • assignee=me|<userId>|unassigned — bugs assigned to the signed-in user, to one user, or to nobody. A named assignee (or me) matches only while that user is an active member of the requesting org; unassigned means what the row displays: no assignee, or an assignee who is no longer an active member (removed, deactivated, or of another org), whose stored id every read masks. The same rule applies on GET /v1/bugs and GET /v1/bugs/groups. Groups expose assignees: string[].

Every scalar param GET /v1/bugs/groups accepts — projectId, status, severity, signalClass, search, platform, includeDiagnostics, assignee, unresolved, sort and direction — is parsed through the same one-value-only rule (parseScalarQueryParam) before any validation runs: ?status=a&status=b (or ?status[]=a) is a clean 400 "status must be provided at most once", never silently the first, the last, or an array reaching the query. GET /v1/bugs and GET /v1/bugs/recommended reject the same way.

GET /v1/bugs/groups additionally accepts:

  • sort=last_seen|first_seen|count|severity|title|signal|assignee and direction=asc|desc. The default is last_seen desc (the pre-existing order, so clients that send neither see no change). title sorts the sample title, signal puts regressions first then signal classes, assignee sorts the group's first display name (name, else email) among assignees who are active members of the requesting org, chosen case-insensitively — the same rule GET /v1/bugs/recommended exposes as assignee_name — then the name itself, with unassigned groups last in both directions; first_seen and last_seen break ties straight on projectId and groupKey ascending; count, severity, title, signal and assignee first break ties on a fixed last_seen desc, then on projectId and groupKey ascending — so a page is the same on every request (the list paginates with limit/offset, not a cursor). assignees on /groups, /search and /recommended contains only users who are active members of the requesting org: a stale assignment to anyone else (removed, deactivated, or of another org) is dropped from the array, from the sort and, on /recommended, from the row's own assigned_to. Unknown values are 400. The console sends both explicitly and defaults to last_seen asc. The console's Recommended tab is a different list (GET /v1/bugs/recommended, the top rows by score) and is ordered client-side over that selected set with the same column contract.

Every paginated bug list ends its ordering on a fixed identity so equal timestamps or scores never reorder between pages: GET /v1/bugs (last_seen desc, project_id, id), /groups (above), /search (fused score, then projectId:groupKey), /groups/:groupKey/instances (last_seen desc, id), /:id/occurrences (occurred_at desc, id) and /recommended (recommended_score desc, last_seen desc, id). A group's representative (sampleBugId, sampleTitle, sampleDescription, signatureCanonical, signalReason) is its most recently seen member, ties broken on the greater bug id, in /groups, /search and the stats queries alike.

GET /v1/bugs/recommended stays one row per bug, ranked by recommended_score desc, last_seen desc, id; total is the number of selected rows (bugs), not groups. Each row also carries the totals of its legacy signature group inside its own project, computed with the /groups rules: group_key (signature_hash, or the bug id when unsigned; unique only together with project_id), instance_count (bugs in the group), report_total (sum of the members' report_count), occurrence_count (occurrence rows across every member, or report_total when the group has none — the grouped Count; the row's own report_count is the representative's value and stays for compatibility), the group's first_seen / last_seen range, assignees (distinct assignee user ids across the members who are active members of the requesting org, raw ids as /groups returns them), the bug's own assigned_to (null unless that user is an active member), and assignee_name, the group's sort key: the case-insensitive minimum display name (name, else email) among those assignees, null when none is. Every aggregate is scoped by the org and the row's project, and names resolve only through the org's team_memberships with users.status = 'active', so a foreign, removed or deactivated user id never yields a name. A console that shows one row per group keeps the best-ranked bug as the representative and expands the group through /groups/:groupKey/instances?projectId=. /recommended also accepts the inbox's visible filters — search (alias q), severity, signalClass, platform and assignee=me|unassigned|<userId> — applied before the ranking and the limit and matched at the group level: a group is selected when any member matches the search text, platform, signal class or active same-org assignee (unassigned means no member has one), and severity matches the group's maximum severity; a selected row's group totals are never reduced by a filter. A malformed assignee, severity, signalClass or platform is 400, and so is any repeated (array-valued) assignee, severity, signalClass, platform, search/q, projectId, since or limit (/bugs and /bugs/groups reject a repeated assignee the same way); a well-formed assignee id that is not an active member of the org selects nothing. Because the filters match at the group level, each row also carries the group's displayed evidence, computed with the /groups rules over the unfiltered project-and-org-scoped member and occurrence sets: group_severity (the maximum member severity — what severity compares and what a grouped view shows and sorts by; severity / recommended_severity stay the representative's own values), member_ids, signal_classes, signal_families, any_regression, platforms (occurrence platforms, first-seen platforms as fallback), browsers, oses, session_count and user_count. Every array is ordered ascending, so a group reads byte-for-byte the same whatever order its members were inserted, and no filter ever shrinks these aggregates.

Every group (in /groups and /search) carries two read-only grouping v3 shadow fields. They describe evidence and never change routing:

  • groupingV3null, or the latest capture check for the group: { version: 3, occurrenceId, observedAt, routable, isolated, reason, confidence }. reason is one of v3_exact_operation, v3_exact_operation_and_native_site, v3_native_symbol_site, v3_native_image_offset, v3_isolated_missing_identity, v3_isolated_ambiguous_operation, v3_isolated_insufficient_evidence or insufficient_legacy_evidence; confidence is high or none. Key hashes and canonical bytes are never exposed.
  • duplicateCandidateCount — open proposals whose other canonical issue sits in a different displayed group. A pair the group already collapses is not counted.

/search hydrates occurrence aggregates (occurrenceCount, sessionCount, userCount, platforms) and signalReason with the same SQL semantics as /groups, keyed by projectId + groupKey. Identical signatures in two projects stay two groups.

GET /v1/bugs/groups/:groupKey/instances accepts projectId. A group key is only unique inside a project: when the key exists in more than one of the caller's projects and projectId is omitted, the route returns 409 with code: "GROUP_KEY_AMBIGUOUS".

The shadow storage behind these fields is opt-in per project through projects.grouping_v3_shadow_enabled (default false, SQL-only). It observes and proposes; it never admits, merges, accepts or rejects. Details: docs/plans/2026-09-09-grouping-v3-shadow-qualification.md.

POST /v1/ingest/presence

Presence beacon — lightweight upsert of "this session is currently on this page" into the live_presence table. Triggers an SSE presence event with an org-wide snapshot so the Ops Dashboard can paint active visitors.

Auth: X-BugTape-Key Rate limit: 100/min per IP (shared with ingest)

Request body:

{
  sessionId: string,            // required, 1-200 chars
  userId?: string,              // 0-200 chars
  reporterId?: string,          // 0-200 chars
  url?: string,                 // 0-2000 chars
  path?: string,                // 0-1000 chars
  title?: string,               // 0-500 chars
  referrer?: string,            // 0-2000 chars
  eventType?: string,           // 0-100 chars — e.g. "pageview"
  interaction?: object,         // arbitrary interaction payload
  metadata?: object,            // arbitrary metadata
  visibilityState?: string      // 0-50 chars
}

Success (202):

{ "ok": true }

Error codes: 400 Validation failed · 401 Missing or invalid API key.

POST /v1/ingest/ai-summary

Server-side AI summary for a bug report. Called by the SDK just before submitting a bug when the caller has not configured a client-side openaiKey. The server uses its configured multi-provider AI (openai / anthropic / google) to produce a structured summary; nothing is persisted, this is a summarize-only call.

Auth: X-BugTape-Key Rate limit: 100/min per IP (shared with ingest)

Request body:

{
  events: Array<object>,         // required, min 1. Server hard-caps at 500 (silent truncation).
  title?: string,                // 0-500 chars
  url?: string,                  // 0-2000 chars
  context?: {
    environment?: string,        // 0-100 chars
    release?: string,            // 0-100 chars
    userAgent?: string,          // 0-500 chars
    userId?: string              // 0-200 chars
  }
}

Success (200):

{
  "title": "Checkout submit fails with 500 after coupon applied",
  "severity": "high",
  "description": "Console shows TypeError during cart recompute; backend returns 500 on /api/checkout POST immediately after coupon applied.",
  "truncated": false,
  "provider": "openai",
  "model": "gpt-4.1-nano"
}

When the caller sends more than 500 events, truncated is true and the tail is silently discarded before summarisation.

Error codes: 400 Validation failed (e.g. missing events) · 401 Missing or invalid API key · 429 Rate limit exceeded · 503 AI provider not configured on the server — SDK falls back to submitting the bug without an AI summary.


Bugs (read)

All read endpoints require JWT auth and org scoping: Authorization: Bearer <token> + X-BugTape-Org: <uuid>.

GET /v1/bugs

List grouped bugs for the organization, with per-bug reporter/occurrence aggregates.

Auth: Bearer JWT + X-BugTape-Org

Query parameters:

ParamTypeDefaultDescription
projectIduuidFilter by project
statusstringnew, known, working, resolved, silenced
severitystringcritical, high, medium, low
signalClassstringissue, watch, diagnostic, or all
includeDiagnosticsboolfalseInclude diagnostic signals when signalClass is not set
searchstringFull-text ILIKE on title and description
limitnumber50Page size (max 200)
offsetnumber0Pagination offset

Success (200):

{
  "bugs": [
    {
      "id": "uuid",
      "title": "string",
      "description": "string",
      "severity": "critical|high|medium|low",
      "status": "new|known|working|resolved|silenced",
      "url": "string",
      "fingerprint": "string",
      "report_count": 5,
      "occurrence_count": 5,
      "affected_user_count": 3,
      "affected_session_count": 4,
      "latest_release": "2026.03.08",
      "latest_environment": "production",
      "is_regression": false,
      "signal_class": "issue",
      "signal_family": "runtime_failure",
      "signal_reason": "...",
      "classifier_confidence": "medium",
      "ai_summary": { "...": "..." },
      "project_id": "uuid",
      "project_name": "Default",
      "reporter_count": 2,
      "created_at": "ISO 8601",
      "updated_at": "ISO 8601"
    }
  ],
  "total": 42,
  "limit": 50,
  "offset": 0
}

GET /v1/bugs/stats

Aggregate counts, a daily timeline, top-10 unresolved bugs, MTTR, the previous period for trend comparison, and a weighted health score.

Auth: Bearer JWT + X-BugTape-Org

Query parameters: projectId (optional uuid), days (default 30, 1-365), deliveryFeed (all | urgent_critical | ui_ux; humans default all, PATs use the stored PAT then project feed).

Success (200):

{
  "aggregates": {
    "total": 120, "open": 18, "status_new": 5, "status_known": 4,
    "status_working": 9, "status_resolved": 100, "status_silenced": 2,
    "sev_critical": 2, "sev_high": 6, "sev_medium": 10, "sev_low": 102,
    "total_reports": 318, "avg_resolution_hours": 11.2
  },
  "timeline": [ { "date": "2026-04-01", "bug_count": 3, "report_count": 7 } ],
  "topBugs": [
    { "id": "uuid", "title": "...", "severity": "high", "status": "new",
      "url": "...", "report_count": 41, "first_seen": "...", "last_seen": "..." }
  ],
  "mttr_hours": 11.2,
  "health_score": 47,
  "previous": { "total": 95, "open": 14, "status_new": 6, "status_resolved": 75,
                "sev_critical": 1, "sev_high": 5, "total_reports": 210,
                "avg_resolution_hours": 14.5 },
  "deliveryFeed": "all"
}

The health score weights: critical × 10 + high × 5 + medium × 2 + low × 1 + regressions × 20. Lower is better.

GET /v1/bugs/live

Most recent bug occurrences across the org — used by the live-monitoring view.

Auth: Bearer JWT + X-BugTape-Org

Query parameters: limit (default 50, max 200), includeDiagnostics (default false), projectId (optional uuid), deliveryFeed (all | urgent_critical | ui_ux).

Success (200):

{
  "occurrences": [
    {
      "id": "uuid", "bug_id": "uuid", "project_id": "uuid",
      "reporter_email": "...", "reporter_id": "...",
      "session_id": "...", "end_user_id": "...",
      "release_version": "...", "environment": "production",
      "url": "...", "browser": "Chrome 123", "os": "macOS",
      "event_count": 42, "occurred_at": "ISO 8601",
      "title": "...", "status": "new", "severity": "high",
      "signal_class": "issue", "signal_family": "runtime_failure",
      "signal_reason": "...", "classifier_confidence": "medium",
      "report_count": 5, "project_name": "Default"
    }
  ],
  "limit": 50,
  "deliveryFeed": "all"
}

GET /v1/bugs/presence

Active-visitor snapshot for the Ops Dashboard — counts sessions/users active in the last 75 seconds and the top 5 paths.

Auth: Bearer JWT + X-BugTape-Org

Success (200):

{
  "active_sessions": 12,
  "active_users": 8,
  "pages": [
    { "path": "/checkout", "active_sessions": 5 },
    { "path": "/",         "active_sessions": 3 }
  ]
}

Error codes: 400 Organization ID required.

GET /v1/bugs/export

Export bugs as JSON or CSV, with cursor-based pagination via last_seen.

Auth: Bearer JWT + X-BugTape-Org

Query parameters:

ParamTypeDefaultDescription
formatjson / csvjsonOutput format
statusstringComma-separated (new,known)
severitystringComma-separated (critical,high)
projectIduuidRestrict to one project
deliveryFeedall / urgent_critical / ui_uxall (humans)Facet feed; PATs use stored PAT then project
limitnumber10000Max rows (hard cap 50000)
afterISO 8601Cursor — only bugs with last_seen > after

JSON success (200):

{
  "bugs": [
    { "id": "uuid", "title": "...", "severity": "high", "status": "new",
      "report_count": 5, "url": "...",
      "first_seen": "...", "last_seen": "...", "created_at": "..." }
  ],
  "count": 500,
  "truncated": false,
  "deliveryFeed": "all"
}

CSV success (200): Content-Type: text/csv. Columns: id, title, severity, status, report_count, url, first_seen, last_seen, created_at.

Response headers include X-Query-Ms, X-Row-Count, and X-Next-After (next cursor, only when the limit was hit).

GET /v1/bugs/:id

Get a single grouped bug with reporters, recent occurrences, and aggregate counts.

Auth: Bearer JWT + X-BugTape-Org

Success (200): a single bug row (same shape as list items) plus reporters, recent_occurrences, occurrence_count, session_count, and affected_user_count, plus two read-only grouping v3 shadow fields: groupingV3 (the latest capture check for this canonical issue, same shape as on groups, or null) and duplicateCandidates (up to 20 open proposals whose other canonical issue sits in a different displayed group: { id, otherIssueId, otherTitle, score, reason, confidence, algorithmVersion, state, createdAt }). There is no accept/reject endpoint; these are evidence, not actions.

Error codes: 404 Bug not found.

GET /v1/bugs/:id/occurrences

List individual occurrences grouped under a bug.

Auth: Bearer JWT + X-BugTape-Org

Query parameters: limit (default 50, max 200), offset.

Success (200):

{
  "occurrences": [
    { "id": "uuid", "bug_id": "uuid", "project_id": "uuid",
      "reporter_email": "...", "reporter_id": "...", "ip_hash": "...",
      "session_id": "...", "end_user_id": "...",
      "release_version": "...", "environment": "...",
      "url": "...", "user_agent": "...", "viewport": "...",
      "browser": "...", "os": "...",
      "event_count": 42, "metadata": { "...": "..." },
      "occurred_at": "...", "created_at": "..." }
  ],
  "total": 100,
  "limit": 50,
  "offset": 0
}

Error codes: 404 Bug not found.

GET /v1/bugs/:id/events

Fetch bug events, optionally filtered by type and/or occurrence.

Auth: Bearer JWT + X-BugTape-Org

Query parameters: type (event type), occurrenceId (uuid), limit (default 100, max 1000).

Success (200):

{
  "events": [
    { "id": "uuid", "bug_id": "uuid", "occurrence_id": "uuid",
      "event_type": "error", "timestamp": "...",
      "data": { "...": "..." } }
  ]
}

Error codes: 404 Bug not found.

GET /v1/bugs/:id/history

Status-change audit trail, newest first.

Auth: Bearer JWT + X-BugTape-Org

Success (200):

{
  "history": [
    { "id": "uuid", "old_status": "new", "new_status": "working",
      "note": "optional note", "changed_at": "...",
      "changed_by_name": "Jack", "changed_by_email": "jack@bugtape.dev" }
  ]
}

Error codes: 404 Bug not found.

GET /v1/bugs/:id/comments

List comments for a bug, oldest first.

Auth: Bearer JWT + X-BugTape-Org

Success (200):

{
  "comments": [
    { "id": "uuid", "bug_id": "uuid", "user_id": "uuid",
      "user_name": "Jack", "user_email": "jack@bugtape.dev",
      "content": "Looking into this...", "created_at": "..." }
  ]
}

Error codes: 404 Bug not found.

GET /v1/bugs/:id/watching

Returns whether the current user watches this bug.

Auth: Bearer JWT + X-BugTape-Org

Success (200):

{ "watching": true }

Error codes: 404 Bug not found.

GET /v1/bugs/:id/similar

Find related bugs by fingerprint-prefix match and title-word overlap, scored out of 100 and returned top-5.

Auth: Bearer JWT + X-BugTape-Org

Success (200):

{
  "similar": [
    { "id": "uuid", "title": "...", "severity": "high", "status": "new",
      "url": "...", "report_count": 5, "first_seen": "...",
      "last_seen": "...", "fingerprint": "abc123...",
      "similarity_score": 80 }
  ]
}

Error codes: 404 Bug not found.

GET /v1/bugs/:id/anomalies

Heuristic anomaly detection over the bug's events (error cascades, failed-request storms, timeout cascades, rage clicks, auth-refresh loops, request loops, console-error floods, compound failures).

Auth: Bearer JWT + X-BugTape-Org

Success (200):

{
  "anomalies": [
    { "type": "error_cascade", "severity": "high",
      "description": "...", "evidence": ["...", "..."],
      "timeRange": "12:00:01.234 - 12:00:03.100" }
  ],
  "eventCount": 132
}

Error codes: 404 Bug not found.

GET /v1/bugs/stream

Server-Sent Events stream for real-time bug events. A current expiring human access token is required. The stream checks account revision, membership (or support session) and current project ownership before each delivery. Heartbeats every15s refresh access and the project list. JWT expiry closes the stream immediately; idle account/workspace revocation closes within15s plus at most5s for authorization. Lookup outages, slow readers and a backlog above100 pending events close the stream; reconnect and fetch current state. Startup lookup outages return503; rejected sessions return401. Already-admitted concurrent work is not retroactively cancelled.

Auth: Bearer JWT + X-BugTape-Org

Event types emitted: new_bug, duplicate, regression, status_change, deleted, comment, presence. Payloads include fields such as title, severity, url, occurrenceId, sessionId, userId, release, environment, browser, os, and signal-classification fields.

GET /v1/bugs/reporters/:email/bugs

All bugs reported by a specific email address.

Auth: Bearer JWT + X-BugTape-Org

:email is URL-encoded.

Corrected 2026-08-01. This was previously documented as GET /v1/reporters/:email/bugs, with a note claiming the parent mount exposed it there. It does not: that path returns 404, and the working path is /v1/bugs/reporters/:email/bugs (verified against the running stack). Anyone integrating from the old entry hit a dead end.

Success (200):

{
  "email": "user@example.com",
  "bugs": [
    { "id": "uuid", "title": "...", "severity": "high", "status": "new",
      "url": "...", "first_seen": "...", "last_seen": "...",
      "report_count": 5, "reported_at": "..." }
  ],
  "stats": {
    "total_bugs": 3,
    "first_report": "ISO 8601",
    "last_report": "ISO 8601",
    "severities": { "critical": 1, "high": 1, "medium": 1 }
  }
}

Bugs (write)

POST /v1/bugs/manual

Create a bug manually from the console (tester / admin flows). The request is subject to the same fingerprint-based deduplication as SDK ingest.

Auth: Bearer JWT + X-BugTape-Org

Request body:

{
  "projectId": "uuid",
  "title": "string, 3-500 chars",
  "description": "optional, up to 10,000 chars",
  "severity": "critical | high | medium | low",
  "url": "optional, up to 2,000 chars",
  "environment": "optional, up to 100 chars"
}

Success (201):

{ "id": "uuid", "occurrenceId": "uuid", "deduplicated": false }

Error codes: 400 Validation failed · 402 Plan limit reached · 404 Project not found.

PATCH /v1/bugs/:id

Update a single bug's status, severity, assignment, and/or append a note to status history.

Auth: Bearer JWT + X-BugTape-Org

Request body:

{
  "status": "new | known | working | resolved | silenced",
  "severity": "critical | high | medium | low",
  "assignedTo": "user-uuid or null",
  "note": "optional note recorded in status history"
}

Resolving a bug sets resolved_at. Reopening a resolved bug flips is_regression = true and emits a regression webhook + SSE event. Watchers who are active members of the bug's org receive status_changed notifications (a watcher row left by a removed or deactivated member is inert, as it is for comment notifications); assignees are auto-added to watchers and receive an assigned notification. assigned_to on GET /v1/bugs rows and GET /v1/bugs/:id is returned only while it names an active member of the requesting org; a stale stored id reads as null and the response shape is unchanged. assignedTo is null/'' to unassign, otherwise the UUID of an active user who is a member of the requesting org (a pending invitee is rejected until they activate). It is checked before anything is read or written, so a rejected assignment changes no row, adds no watcher, and sends no notification or webhook — even when status or severity was sent alongside. The response is the same 400 ASSIGNEE_INVALID for a malformed id, a non-member, a user of another org, and an id that does not exist.

Success (200):

{ "success": true }

Error codes: 400 Invalid status / severity · 400 ASSIGNEE_INVALID assignedTo must be the id of an active member of this organisation · 404 Bug not found.

PATCH /v1/bugs/bulk

Batch update up to 100 bugs in one call.

Auth: Bearer JWT + X-BugTape-Org

Request body:

{
  "ids": ["uuid", "uuid", "..."],
  "groupRefs": [{ "projectId": "uuid", "groupKey": "signature-or-bug-id" }],
  "groupKeys": ["legacy signature key", "..."],
  "status": "new | known | working | resolved | silenced",
  "severity": "critical | high | medium | low",
  "assignedTo": "user-uuid or null"
}

At least one of ids, groupRefs, groupKeys (max 100 each) and at least one of status, severity, assignedTo must be provided. groupRefs is the canonical group selector: each { projectId, groupKey } pair expands to every unmerged bug in that project whose COALESCE(signature_hash, id::text) matches, after the project is verified to belong to the caller's organisation. groupKeys is the legacy selector and is accepted only while each key identifies exactly one project in the organisation. updated counts every affected bug.

Success (200):

{ "success": true, "updated": 42 }

Error codes: 400 ids or groupKeys or groupRefs (array) is required / Cannot update more than 100 bugs at once / At least one field (status, severity, assignedTo) is required / Invalid status or severity / malformed groupRefs / ASSIGNEE_INVALID (same rule and same response as PATCH /v1/bugs/:id, checked before the ownership reads) · 404 One or more bugs not found / One or more group reference projects not found · 409 GROUP_KEY_AMBIGUOUS when a legacy groupKey exists in several projects (retry with groupRefs); nothing is updated.

POST /v1/bugs/:id/comments

Add a comment to a bug. Auto-subscribes the commenter. Mentions (@user@example.com) notify mentioned teammates who are active members of the bug's org and auto-subscribe them; a pending, deactivated or foreign-org address gets no notification and no watcher row.

Auth: Bearer JWT + X-BugTape-Org

Request body:

{ "content": "string, up to 10,000 chars" }

Success (201):

{
  "id": "uuid", "bug_id": "uuid", "user_id": "uuid",
  "user_name": "Jack", "user_email": "jack@bugtape.dev",
  "content": "...", "created_at": "ISO 8601"
}

Error codes: 400 Comment content is required / too long · 404 Bug not found.

POST /v1/bugs/:id/watch

Subscribe the current user to this bug's notifications.

Auth: Bearer JWT + X-BugTape-Org

Success (200): { "watching": true }

Error codes: 404 Bug not found.

DELETE /v1/bugs/:id/watch

Unsubscribe the current user.

Auth: Bearer JWT + X-BugTape-Org

Success (200): { "watching": false }

Error codes: 404 Bug not found.

POST /v1/bugs/merge

Merge up to 20 source bugs into a target bug. Reporters, occurrences, events, comments, attachments, watchers, and status history are re-parented to the target; source bugs are deleted. Counts and timestamps are aggregated.

Auth: Bearer JWT + X-BugTape-Org

Request body:

{
  "sourceIds": ["uuid", "uuid", "..."],
  "targetId": "uuid"
}

Success (200):

{ "success": true, "targetId": "uuid", "mergedCount": 3 }

Error codes: 400 Missing ids / target-in-sources / too many bugs · 404 One or more bugs not found · 500 Merge failed.

DELETE /v1/bugs/:id

Permanently delete a bug and all cascading data (reporters, occurrences, events, attachments, watchers, status history, comments). Emits a deleted SSE event.

Auth: Bearer JWT + X-BugTape-Org

Success (200):

{ "success": true, "deleted": "uuid" }

Error codes: 404 Bug not found.

POST /v1/bugs/:id/analyze

Deep AI analysis of a bug and up to 500 of its events. Returns a root cause, a causal chain, severity assessment, suggested fix, and a named pattern classification. Tracked against the org's AI usage.

Auth: Bearer JWT + X-BugTape-Org

Success (200):

{
  "analysis": {
    "rootCause": { "summary": "...", "category": "authentication", "technicalDetail": "..." },
    "causalChain": [
      { "step": 1, "event": "...", "timestamp": "ISO 8601",
        "role": "root_cause", "explanation": "..." }
    ],
    "severity": { "level": "high", "reasoning": "...", "userImpact": "..." },
    "suggestedFix": { "summary": "...", "steps": ["...", "..."] },
    "pattern": { "name": "AUTH_TOKEN_REFRESH_LOOP", "confidence": "high" },
    "confidence": "medium"
  },
  "stats": {
    "total_events": 180, "errors": 5, "network": 80,
    "console": 40, "user_actions": 10,
    "anomalies": ["3 failed HTTP requests"],
    "duration_seconds": 18.4
  }
}

Error codes: 404 Bug not found · 500 Analysis failed · 503 AI service not configured.

POST /v1/bugs/:id/events/:eventId/ai

AI assessment of a single event. Two modes: explain (default) or fix.

Auth: Bearer JWT + X-BugTape-Org

Query parameters: mode=explain|fix.

Success (200):

{ "assessment": "plain-text assessment", "mode": "explain" }

Error codes: 404 Event not found · 500 AI assessment failed · 503 AI service not configured.


Projects and API keys

GET /v1/projects

List projects for the caller's org, with bug counts.

Auth: Bearer JWT + X-BugTape-Org

Success (200):

{
  "projects": [
    { "id": "uuid", "org_id": "uuid", "name": "Default", "slug": "default",
      "platform": "...", "created_at": "...",
      "bug_count": 12, "new_bug_count": 3 }
  ]
}

POST /v1/projects

Create a project. Requires role admin, manager, or member.

Auth: Bearer JWT + X-BugTape-Org

Request body:

{ "name": "Project Name", "orgId": "uuid" }

orgId must match the caller's org.

Success (201):

{ "id": "uuid", "name": "Project Name", "slug": "project-name" }

Error codes: 400 name and orgId required · 402 Plan limit reached · 403 Organization mismatch.

POST /v1/projects/:id/keys

Generate a new API key for a project. Requires role admin, manager, or member.

Auth: Bearer JWT + X-BugTape-Org

Request body:

{ "env": "live", "label": "iPhone production", "expiresAt": null }

env is "live" (default) or "test"; other values are rejected. It selects the key prefix, not a separate environment or data store. Use a separate project for isolation. Optional label is trimmed, 1–80 characters. Optional expiresAt is an ISO timestamp in the future, at most 365 days away. Omitted/null expiry keeps the key active until revoked, preserving existing clients. Existing keys are not changed. Creation and revocation recheck role and active account state under the same organization lock as team changes. The key can capture within its project; it cannot act as a human JWT or agent PAT.

Success (201):

{
  "key": "bt_live_abcdefghijklmnopqrstuvwxyz123456",
  "note": "Save this key — it will not be shown again."
}

Error codes: 400 Invalid label/environment/expiry · 403 Current role/account cannot manage keys · 404 Project not found.

GET /v1/projects/:id/keys

List a project's API keys. Only prefix and metadata are returned (the raw key is never stored in plaintext).

Auth: Bearer JWT + X-BugTape-Org

Success (200):

{
  "keys": [
    { "id": "uuid", "key_prefix": "bt_live_abc", "label": "default",
      "is_active": true, "last_used": "...", "created_at": "...", "expires_at": null }
  ]
}

DELETE /v1/projects/:id/keys/:keyId

Soft-delete (revoke) an API key by setting is_active = false. Requires role admin, manager, or member.

Auth: Bearer JWT + X-BugTape-Org

Success (200):

{ "success": true, "revoked": "bt_live_abc" }

Error codes: 404 API key not found or already revoked.


Team

GET /v1/team

List the current org's team members with their notification preferences.

Auth: Bearer JWT + X-BugTape-Org

Success (200):

{
  "members": [
    { "id": "uuid", "email": "...", "name": "...", "avatar_url": null,
      "role": "admin", "joined_at": "...",
      "email_level": "full", "slack_enabled": false,
      "in_app_enabled": true, "digest_freq": "realtime" }
  ]
}

POST /v1/team/invite

Invite a team member by email. Creates a pending_invite user when needed. A new teammate receives a one-use48-hour setup link; an active teammate receives a workspace sign-in link. Requires role admin or manager.

Auth: Bearer JWT + X-BugTape-Org

Request body:

{
  "email": "teammate@example.com",
  "role": "admin | manager | member | tester | viewer",
  "orgId": "uuid"
}

orgId must match the caller's org. Role defaults to "member". Optional resend:true requires an existing membership and preserves its current role. Resending invalidates the previous setup link.

Success (201):

{ "success": true, "userId": "uuid", "invitation": { "state": "pending", "emailStatus": "queued", "expiresAt": "ISO8601" } }

Error codes: 400 Validation failed · 402 Plan limit reached · 403 Organization mismatch.

state is pending or active; active accounts have null expiresAt. emailStatus:queued means the queue accepted the mail, not that the recipient received it. unavailable means no configured provider or no queue acceptance; the membership remains and an administrator can retry. Setup secrets are never returned to the administrator.

PATCH /v1/team/:userId

Change a team member's role. Requires role admin or manager. You cannot downgrade yourself to viewer.

Auth: Bearer JWT + X-BugTape-Org

Request body:

{ "role": "admin | manager | member | tester | viewer" }

Success (200):

{
  "success": true,
  "membership": { "org_id": "uuid", "user_id": "uuid", "role": "member" }
}

Error codes: 400 Invalid role / Cannot downgrade yourself to viewer · 404 Member not found.

DELETE /v1/team/:userId

Remove a team member from the org (and their notification prefs). In the same transaction their assignments on this org's bugs are cleared and their watcher rows on this org's bugs removed, so nothing keeps routing work or notifications to them here; bugs, comments, history and their memberships and data in other orgs are untouched. Requires role admin or manager. You cannot remove yourself.

Auth: Bearer JWT + X-BugTape-Org

Success (200):

{ "success": true }

Error codes: 400 Cannot remove yourself from the team · 404 Member not found.

GET /v1/team/activity

Recent activity feed across the org (comments, status changes, new bugs), newest first.

Auth: Bearer JWT + X-BugTape-Org

Query parameters: limit (default 30, max 100).

Success (200):

{
  "activity": [
    { "type": "comment", "actor": "Jack",
      "summary": "comment text…", "bug_title": "...", "bug_id": "uuid",
      "ts": "ISO 8601" },
    { "type": "status_change", "actor": "Jack",
      "summary": "new → working", "bug_title": "...", "bug_id": "uuid",
      "ts": "ISO 8601" },
    { "type": "new_bug", "actor": "system",
      "summary": "...", "bug_title": "...", "bug_id": "uuid",
      "ts": "ISO 8601" }
  ]
}

PATCH /v1/team/:userId/notifications

Update a team member's notification preferences.

Auth: Bearer JWT + X-BugTape-Org

Request body (all optional):

{
  "emailLevel": "full | notification | digest | off",
  "slackEnabled": true,
  "slackWebhook": "https://hooks.slack.com/...",
  "inAppEnabled": true,
  "digestFreq": "realtime | hourly | daily"
}

Success (200):

{ "success": true }

Notifications

All notification endpoints require org scoping.

GET /v1/notifications

List in-app notifications for the current user in the current org, plus the unread count.

Auth: Bearer JWT + X-BugTape-Org

Query parameters: unread=true (only unread), limit (default 50, max 200).

Success (200):

{
  "notifications": [
    { "id": "uuid", "user_id": "uuid", "org_id": "uuid",
      "type": "assigned | comment | mention | status_changed",
      "title": "...", "body": "...", "bug_id": "uuid", "actor_id": "uuid",
      "read": false, "created_at": "..." }
  ],
  "unreadCount": 4
}

POST /v1/notifications/:id/read

Mark a single notification as read.

Auth: Bearer JWT + X-BugTape-Org

Success (200): { "success": true }

Error codes: 404 Notification not found.

POST /v1/notifications/read-all

Mark all of the user's notifications in this org as read.

Auth: Bearer JWT + X-BugTape-Org

Success (200): { "success": true }


Integrations

Integrations drive Slack, Discord, GitHub Issues, and generic webhook deliveries. All routes require org scoping.

GET /v1/integrations

List integrations for a project. Sensitive config values (tokens, secrets, full webhook URLs) are redacted.

Auth: Bearer JWT + X-BugTape-Org

Query parameters: projectId (uuid, required).

Success (200):

{
  "integrations": [
    { "id": "uuid", "type": "slack | discord | webhook | github",
      "name": "...", "config": { "url": "https://hooks.slack.com/***", "token": "***" },
      "events": ["new_bug", "regression", "status_change"],
      "is_active": true, "created_at": "...", "updated_at": "..." }
  ]
}

Error codes: 400 projectId is required · 404 Project not found.

POST /v1/integrations

Create an integration.

Auth: Bearer JWT + X-BugTape-Org

Request body:

{
  "projectId": "uuid",
  "type": "slack | discord | webhook | github",
  "name": "Optional label",
  "config": {
    "url": "https://hooks.slack.com/services/...",
    "secret": "optional HMAC secret for generic webhook",
    "token": "GitHub token (github only)",
    "repo": "owner/repo (github only)"
  },
  "events": ["new_bug", "regression", "status_change"]
}

SSRF guard: webhook URLs that resolve as loopback, link-local, private, multicast, or IPv4-mapped IPv6 addresses are rejected. In production, non-HTTPS URLs are also rejected.

Success (201):

{
  "id": "uuid",
  "type": "slack",
  "name": "...",
  "events": ["new_bug", "regression", "status_change"],
  "is_active": true,
  "created_at": "..."
}

Error codes: 400 Validation failed / Invalid webhook URL / Private URL / Bad GitHub repo format · 402 Plan limit reached · 404 Project not found.

PATCH /v1/integrations/:id

Update any of name, config, events, is_active.

Auth: Bearer JWT + X-BugTape-Org

Request body:

{
  "name": "string",
  "config": { "...": "..." },
  "events": ["new_bug"],
  "is_active": true
}

Success (200): the updated integration row.

Error codes: 400 No fields to update · 404 Integration not found.

DELETE /v1/integrations/:id

Delete an integration.

Auth: Bearer JWT + X-BugTape-Org

Success (200): { "success": true }

Error codes: 404 Integration not found.

POST /v1/integrations/:id/test

Send a test payload through the integration.

Auth: Bearer JWT + X-BugTape-Org

Success (200): { "success": true }

Error codes: 404 Integration not found · 502 Webhook delivery failed.

GET /v1/integrations/:id/deliveries

List retained deliveries and delivery results, ordered by creation time ascending.

Auth: Bearer JWT + X-BugTape-Org

Query parameters: limit (default 20, max 100), offset, optional status (pending, deferred, success, failed, canceled). Filters apply across all history; total counts matching records.

Success (200):

{
  "deliveries": [
    { "id": "uuid", "event_type": "new_bug",
      "status": "success", "attempts": 1,
      "response_code": 200, "error_message": null,
      "created_at": "...", "completed_at": "...",
      "deferred_until": null, "can_retry": false }
  ],
  "total": 42,
  "limit": 20,
  "offset": 0
}

Error codes: 404 Integration not found.

POST /v1/integrations/:id/deliveries/:deliveryId/retry

Retry a single failed delivery only when can_retry is true. Current connection settings, subscriptions and policy are checked again. Capture delivery retries share the queue receipt lock and a five-attempt outbound budget. Quiet-hour checks and failed database preparation do not spend that budget.

Auth: Bearer JWT + X-BugTape-Org

Success (200): { "success": true }

Error codes: 404 Retryable delivery not found / Integration not found · 409 Delivery held or excluded by current rules · 502 Retry failed.

Generic webhook payload

For integrations of type webhook and agent, BugTape POSTs JSON for every event the project's alert policy allows. Events: new_bug, regression, status_change, comment, assigned, recommended_bug, agent_notify, agent_update, test. agent_notify adds trigger (auto | manual); agent_update adds agentAction (working | pr_opened), agentActor and optional prUrl. Neither is held by the alert policy's severity floor or quiet hours; the integration's events list decides. New integrations default to new_bug, regression, status_change, plus agent_notify (agent, webhook) and agent_update (slack, discord, telegram, whatsapp, imessage, webhook). Migration 066 added the same to existing integrations.

{
  "event": "new_bug",
  "source": "bugtape",
  "title": "Bug Title",
  "severity": "high",
  "url": "https://example.com/page",
  "bugId": "uuid",
  "projectId": "uuid",
  "occurrenceId": "uuid",
  "fingerprint": "a1b2c3d4e5f60718",
  "platform": "ios",
  "release": "1.4.0",
  "environment": "production",
  "userId": "u_123",
  "sessionId": "s_…",
  "status": "new",
  "oldStatus": "resolved",
  "reportCount": 5,
  "description": "...",
  "consoleUrl": "https://app.bugtape.ai/console/issues/<id>",
  "userEmail": "user@example.com",
  "evidence": {
    "mcp": "get_repro_context({ bugId: \"…\", occurrenceId: \"…\" })",
    "events": "https://app.bugtape.ai/v1/bugs/<id>/events?occurrenceId=<occ>",
    "occurrences": "https://app.bugtape.ai/v1/bugs/<id>/occurrences",
    "users": "https://app.bugtape.ai/v1/bugs/<id>/users"
  },
  "timestamp": "2026-02-28T12:00:00Z"
}

Ingest events (new_bug, regression) carry the occurrence facts (occurrenceId, platform, release, environment, userId, sessionId, evidence); status/comment/assign events carry what they know. Fields are additive — never removed.

Headers on every delivery:

X-BugTape-Event: <event>
X-BugTape-Signature: sha256=<hmac-hex>        (only when a secret is configured — HMAC-SHA256 of the raw body)
User-Agent: BugTape-Webhook/1.0               (BugTape-AgentWebhook/1.0 for the agent type)

Delivery: one attempt per job, 4 retries with backoff, then the dead-letter log. Alerts → Delivery log shows retained work and results; eligible failed records offer manual retry. Capture webhooks held by quiet hours retain the same delivery ID and wait for a minute policy sweep, including after restart. deferred_until is the next check time, not a promised send time. Permanent exclusions cancel retained work. Manual/legacy status-change alerts are not retained when quiet hours suppress initial routing. Delivery remains at-least-once: a remote acceptance followed by a worker failure can produce a duplicate. Respond 2xx within 10 s. See agents.md for the agent decision matrix (MCP listen vs webhook vs polling).


Org usage

GET /v1/org/usage

Return the caller's org plan, limits, and current usage (bugs this month, projects, team members, integrations, etc.). Used by the Settings → Plan & Usage panel.

Auth: Bearer JWT + X-BugTape-Org

Success (200): the org's usage object (shape produced by getOrgUsage; includes at least plan, per-resource limit / current, and overage flags).

Error codes: 404 Organization not found.

features.mcp is true for Pro/Team/Enterprise and for any org with a prepaid credit balance above 0 ("Pay as you go"). features.mcp_source says why: plan, credits or none.


Billing — Pay as you go, saved card, budgets (2026-09-24, migration 065)

Tiers. Free · Pay as you go · Pro ($29/mo, 10,000 credits a month, about $2.90 per 1,000) · Team · Enterprise. Pay as you go is Free plus prepaid credits: $10 buys 2,000 credits (about $5 per 1,000; Stripe price STRIPE_PRICE_ID_CREDITS). Prepaid credits never expire — the monthly reset touches only the included grant (credit_included), never credit_prepaid.

MCP on Pay as you go. POST /v1/org/agent-pats accepts orgs on Pro/Team/Enterprise or with credit_prepaid > 0; Free with no prepaid credits still gets 402 MCP_PLAN_REQUIRED. For orgs without an MCP plan, each agent-PAT read of GET /v1/bugs/:id/events with limit > 1 costs 2 credits, paid from prepaid credits only (credit_ledger.reason = 'mcp_repro_read'). This is the read behind get_repro_context, get_bug_events and the bugtape://bugs/{id}/repro resource. Reads with limit <= 1 and every other agent read are free. Pro/Team/Enterprise and human sessions are never charged. No balance → 402 { code: "credits_exhausted" }.

Budget rules. A budget is USD cents per UTC calendar month. 0 (the default) means BugTape never charges the saved card automatically. A budget above 0 is a hard cap on every charge path: a credit-pack checkout that would take month-to-date spend over the budget is refused with 402 BUDGET_EXCEEDED before Stripe is called. Month-to-date spend = sum of credit_ledger.amount_cents for paid credit packs (reason = 'credits_pack', delta > 0) this month. An admin-started pack checkout with budget 0 is allowed (the admin pays in Stripe). Auto top-up is off by default and is stored only when a card is on file and the budget is above 0; automatic charging is not live yet.

All three endpoints need a direct workspace-admin session: denyAgentPat + requireOrgAccess('admin') + no support impersonation (same guards as checkout and portal). They return 403 otherwise.

GET /v1/billing/budget

Database-only — works without Stripe keys.

Success (200):

{
  "monthlyBudgetCents": 3000,
  "autoTopUp": false,
  "paymentMethodOnFile": true,
  "monthToDateSpendCents": 1000,
  "month": "2026-09-01"
}

PUT /v1/billing/budget

Body: { "monthlyBudgetCents"?: integer 0..10000000 | null, "autoTopUp"?: boolean } — at least one field. null or 0 means never charge and forces autoTopUp to false.

Success (200): the same shape as GET.

Error codes: 400 VALIDATION_ERROR (bad type, range, unknown field, empty body) · 400 AUTO_TOP_UP_REQUIRES_BUDGET · 400 AUTO_TOP_UP_REQUIRES_CARD · 403.

POST /v1/billing/card-setup-session

Stripe Checkout in mode: "setup": saves a card and charges nothing. Reuses the org's Stripe customer (subscription or earlier saved card); otherwise Checkout creates one (customer_creation: "always").

Body: { "returnTo"?: "setup" | "settings" } (default settings). Returns to /console/setup?billing=card_saved|card_cancel or /console/settings?tab=plan&billing=card_saved|card_cancel.

Success (200): { "url": "https://checkout.stripe.com/…" }

Error codes: 400 bad returnTo · 403 · 501 Billing not configured (STRIPE_SECRET_KEY unset).

Webhook. checkout.session.completed with mode: "setup" (or metadata.kind: "card_setup") stores billing_budgets.stripe_customer_id and sets payment_method_on_file = true. It never creates a subscription or changes organizations.plan. A paid credit pack now records amount_cents (from amount_total) on its ledger row; an unpaid pack session grants nothing.

POST /v1/billing/create-checkout-session — budget check

{ "pack": "credits" } now runs the budget check first: 402 { code: "BUDGET_EXCEEDED", monthlyBudgetCents, monthToDateSpendCents, chargeCents: 1000 } when the pack would go over a budget above 0.


Admin (superadmin only)

Every endpoint below requires Authorization: Bearer <token> for a user whose email is listed in the ADMIN_EMAILS env var. Non-superadmin callers receive 403 Forbidden.

GET /v1/admin/overview

System-wide counts plus 30 days of growth.

Success (200):

{
  "counts": {
    "users": 120, "organizations": 37, "projects": 52,
    "bugs": 1842, "events": 410230, "api_keys": 85, "waitlist_pending": 12
  },
  "growth": [
    { "date": "2026-03-18", "users": 118, "organizations": 36, "bugs": 1801 }
  ]
}

GET /v1/admin/organizations

List all organizations with member/project/bug counts.

Query parameters: search, limit (default 50, max 200), offset.

Success (200): { "organizations": [...], "total": N, "limit": 50, "offset": 0 }

GET /v1/admin/customers

Customer view grouped by org — enriched with primary contact, last activity, and counts.

Query parameters: search, stage (trial|active|paused|churned|internal), plan (free|pro|team|enterprise), billingStatus (unassigned|manual|paid|past_due), limit, offset.

Success (200):

{
  "customers": [
    { "id": "uuid", "name": "...", "slug": "...", "plan": "free",
      "lifecycle_stage": "trial", "billing_status": "manual",
      "admin_notes": "...", "created_at": "...", "updated_at": "...",
      "primary_contact_name": "...", "primary_contact_email": "...",
      "primary_contact_role": "admin", "primary_contact_verified": true,
      "use_case": "...", "waitlist_status": "completed",
      "waitlist_created_at": "...", "waitlist_completed_at": "...",
      "member_count": 3, "project_count": 2, "active_keys": 4,
      "bug_count": 42, "open_bug_count": 5, "last_bug_seen": "...",
      "last_activity_at": "..." }
  ],
  "total": 37, "limit": 50, "offset": 0
}

GET /v1/admin/customers/:orgId

Full customer profile: organization + team + projects.

Success (200):

{
  "customer": { "...": "org row + primary contact + waitlist info" },
  "team": [
    { "id": "uuid", "email": "...", "name": "...",
      "email_verified": true, "last_login": "...",
      "role": "admin", "joined_at": "..." }
  ],
  "projects": [
    { "id": "uuid", "name": "...", "slug": "...", "platform": "...",
      "created_at": "...", "bug_count": 42, "active_keys": 2,
      "last_key_used": "..." }
  ]
}

Error codes: 404 Customer not found.

POST /v1/admin/customers

Create a customer organization manually. If contactEmail matches an existing user, they become the admin and receive an invite email; otherwise a waitlist row is created with a 7-day approval token and the approved-signup email is sent.

Request body:

{
  "orgName": "Acme Corp",
  "contactEmail": "owner@acme.com",
  "contactName": "Optional Name",
  "plan": "free | pro | team | enterprise",
  "lifecycleStage": "trial | active | paused | churned | internal",
  "billingStatus": "unassigned | manual | paid | past_due",
  "useCase": "Optional free-text"
}

Success (201):

{
  "customer": {
    "id": "uuid", "name": "Acme Corp", "slug": "acme-corp",
    "plan": "free", "lifecycle_stage": "trial", "billing_status": "manual"
  },
  "project": { "id": "uuid", "name": "Default", "slug": "default" }
}

Error codes: 400 orgName and contactEmail are required.

POST /v1/admin/customers/:orgId/team

Add (or promote) a workspace member from owner mode. Sends an invite email.

Request body:

{
  "email": "teammate@example.com",
  "name": "Optional Name",
  "role": "admin | manager | member | tester | viewer"
}

Success (201): { "success": true, "userId": "uuid", "role": "member" }

Error codes: 400 Valid email and role are required · 404 Customer not found.

PATCH /v1/admin/customers/:orgId

Update a customer's plan, lifecycle stage, billing status, and/or admin notes.

Request body (at least one field required):

{
  "plan": "free | pro | team | enterprise",
  "lifecycleStage": "trial | active | paused | churned | internal",
  "billingStatus": "unassigned | manual | paid | past_due",
  "adminNotes": "string or empty to clear"
}

Success (200): { "customer": { ...org row... } }

Error codes: 400 Invalid plan / lifecycle stage / billing status / No valid fields to update · 404 Customer organization not found.

GET /v1/admin/users

List all users with their org memberships.

Query parameters: search, limit (default 50, max 200), offset.

Success (200): { "users": [ ... ], "total": N, "limit": 50, "offset": 0 }. Each user has an orgs array [{ orgId, role, orgName }].

GET /v1/admin/waitlist

List waitlist entries with optional filtering.

Query parameters: search, status (pending|approved|rejected|completed), limit, offset.

Success (200): { "entries": [...], "total": N, "limit": 50, "offset": 0 }.

POST /v1/admin/waitlist/:id/approve

Approve a waitlist entry. Issues a 7-day approval token and sends the completion-link email.

Request body: { "note": "optional reviewer note" }

Success (200): { "success": true }

Error codes: 400 Entry already completed signup · 404 Waitlist entry not found.

POST /v1/admin/waitlist/:id/reject

Mark a waitlist entry as rejected and clear any outstanding approval token.

Request body: { "note": "optional reviewer note" }

Success (200): { "success": true }

Error codes: 404 Waitlist entry not found.

POST /v1/admin/waitlist/:id/resend

Issue a fresh approval token for an already-approved entry and resend the email.

Success (200): { "success": true }

Error codes: 400 Only approved entries can be resent · 404 Waitlist entry not found.

POST /v1/admin/support/session

Start an explicit 8-hour owner support session into a customer workspace (used by "view as customer").

Request body: { "orgId": "uuid" }

Success (201):

{
  "session": {
    "id": "uuid", "orgId": "uuid",
    "orgName": "...", "orgSlug": "...",
    "mode": "full", "expiresAt": "ISO 8601"
  }
}

Error codes: 400 orgId is required · 404 Customer not found.

GET /v1/admin/projects

List all projects with the parent org name and counts.

Query parameters: limit (default 50, max 200), offset.

Success (200): { "projects": [...], "total": N, "limit": 50, "offset": 0 }. Each row includes org_name, bug_count, active_keys.

GET /v1/admin/api-keys

List all API keys with their project and org names.

Query parameters: limit (default 50, max 200), offset.

Success (200): { "keys": [...], "total": N, "limit": 50, "offset": 0 }.

GET /v1/admin/activity

Unified recent-actions feed (new bugs, status changes, user signups).

Success (200):

{
  "activity": [
    { "type": "bug_filed | status_change | user_signup",
      "id": "uuid", "summary": "...", "detail": "...", "ts": "ISO 8601" }
  ]
}

GET /v1/admin/db/tables

Row counts for every user table (read from pg_stat_user_tables).

Success (200):

{ "tables": [ { "table_name": "bug_reports", "row_count": 1842 } ] }

GET /v1/admin/db/tables/:table

Browse rows in an allowlisted table, with sensitive columns replaced by '[REDACTED]'.

Allowlist: organizations, users, team_memberships, projects, api_keys, bug_reports, bug_reporters, bug_events, bug_attachments, bug_comments, bug_status_history, notification_preferences, notifications, bug_watchers, refresh_tokens, integrations, password_reset_tokens, ai_usage, integration_deliveries, waitlist_entries.

Redacted columns: password_hash, key_hash, token_hash.

Query parameters: limit (default 50, max 200), offset.

Success (200):

{
  "table": "users",
  "columns": ["id", "email", "...", "password_hash"],
  "rows": [ { "id": "uuid", "email": "...", "password_hash": "[REDACTED]" } ],
  "total": 120, "limit": 50, "offset": 0
}

Error codes: 400 Table '<name>' is not in the allowed list.

GET /v1/admin/api-key-stats

API-key usage overview.

Success (200):

{
  "total_keys": 85,
  "active_keys": 80,
  "used_last_24h": 42,
  "never_used": 5
}

GET /v1/admin/ai-usage

AI usage statistics.

Query parameters: days (default 30, max 90).

Success (200):

{
  "summary": {
    "total_calls": 1200,
    "total_input_tokens": 450000,
    "total_output_tokens": 98000,
    "total_tokens": 548000
  },
  "daily": [ { "date": "2026-04-01", "calls": 18, "tokens": 6200 } ],
  "byFeature": [
    { "feature": "deep_analysis", "provider": "openai", "model": "gpt-4o",
      "calls": 120, "tokens": 320000 }
  ],
  "days": 30
}

CORS

The API accepts requests from origins configured via CORS_ORIGIN (if unset it logs a warning and defaults to same-origin). Hosted Fly sets CORS_ORIGIN=* so customer sites can POST /v1/ingest with X-BugTape-Key. That * is required for multi-tenant ingest today. Do not silently tighten it. Allowed headers:

Content-Type, Authorization, X-BugTape-Key, X-BugTape-Org

Allowed methods: GET, POST, PATCH, DELETE, OPTIONS.


Route index

Quick reference — every route, auth requirement, and a one-line description.

Health

MethodPathAuthDescription
GET/v1/healthnoneProcess + cheap DB ping (Fly liveness; not a ship signal)
GET/v1/health/deepnoneDB, migrations, jobs/DLQ, redis. HTTP 200 can still mean jobs: degraded

Auth

MethodPathAuthDescription
POST/v1/auth/registernoneOpen self-serve signup; creates user and workspace
POST/v1/auth/waitlistnoneJoin the signup waitlist
GET/v1/auth/approval/:tokennoneValidate a waitlist approval token
POST/v1/auth/complete-signupnoneConsume approval token, create account + tokens
POST/v1/auth/loginnonePassword login → access + refresh tokens
POST/v1/auth/refreshnoneSwap refresh token for a fresh access token
GET/v1/auth/meBearer JWTCurrent user profile + org memberships
GET/v1/auth/whats-newBearer JWT + OrgActivity since the user's last login
POST/v1/auth/send-verificationBearer JWTSend verification email to current user
POST/v1/auth/resend-verificationnonePublic resend (enumeration-safe)
POST/v1/auth/verify-emailnoneConsume an email-verification token
POST/v1/auth/forgot-passwordnoneRequest a password-reset link
POST/v1/auth/reset-passwordnoneConsume reset token + set new password

Ingestion

MethodPathAuthDescription
POST/v1/ingestX-BugTape-KeySubmit a bug report
POST/v1/ingest/presenceX-BugTape-KeyLive-presence beacon
POST/v1/ingest/ai-summaryX-BugTape-KeyServer-side AI summary for a pending report

Bugs (read)

MethodPathAuthDescription
GET/v1/bugsBearer JWT + OrgList grouped bugs
GET/v1/bugs/statsBearer JWT + OrgAggregates, MTTR, health score
GET/v1/bugs/liveBearer JWT + OrgRecent occurrences across the org
GET/v1/bugs/presenceBearer JWT + OrgActive visitor snapshot
GET/v1/bugs/exportBearer JWT + OrgExport bugs (JSON or CSV)
GET/v1/bugs/:idBearer JWT + OrgSingle bug with reporters + recent occurrences
GET/v1/bugs/:id/occurrencesBearer JWT + OrgOccurrences under a bug
GET/v1/bugs/:id/eventsBearer JWT + OrgEvents for a bug or occurrence
GET/v1/bugs/:id/historyBearer JWT + OrgStatus-change audit trail
GET/v1/bugs/:id/commentsBearer JWT + OrgList comments
GET/v1/bugs/:id/watchingBearer JWT + OrgIs current user watching?
GET/v1/bugs/:id/similarBearer JWT + OrgSimilar-bugs scoring
GET/v1/bugs/:id/anomaliesBearer JWT + OrgHeuristic anomaly detection
GET/v1/bugs/streamBearer JWT + OrgSSE real-time stream
GET/v1/reporters/:email/bugsBearer JWT + OrgAll bugs by reporter email

Bugs (write)

MethodPathAuthDescription
POST/v1/bugs/manualBearer JWT + OrgCreate a bug from the console
PATCH/v1/bugs/:idBearer JWT + OrgUpdate status / severity / assignment
PATCH/v1/bugs/bulkBearer JWT + OrgBulk update up to 100 bugs
POST/v1/bugs/:id/commentsBearer JWT + OrgAdd comment (auto-subscribe, @mentions)
POST/v1/bugs/:id/watchBearer JWT + OrgWatch a bug
DELETE/v1/bugs/:id/watchBearer JWT + OrgUnwatch a bug
POST/v1/bugs/mergeBearer JWT + OrgMerge sources into a target
DELETE/v1/bugs/:idBearer JWT + OrgPermanently delete a bug + cascades
POST/v1/bugs/:id/analyzeBearer JWT + OrgDeep AI analysis of a bug
POST/v1/bugs/:id/events/:eventId/aiBearer JWT + OrgAI explain/fix for an event

Projects & API keys

MethodPathAuthDescription
GET/v1/projectsBearer JWT + OrgList projects
POST/v1/projectsBearer JWT + OrgCreate a project
POST/v1/projects/:id/keysBearer JWT + OrgGenerate an API key
GET/v1/projects/:id/keysBearer JWT + OrgList API keys (masked)
DELETE/v1/projects/:id/keys/:keyIdBearer JWT + OrgRevoke an API key (soft-delete)

Team

MethodPathAuthDescription
GET/v1/teamBearer JWT + OrgList team members
POST/v1/team/inviteBearer JWT + OrgInvite by email
PATCH/v1/team/:userIdBearer JWT + OrgChange role
DELETE/v1/team/:userIdBearer JWT + OrgRemove from org
GET/v1/team/activityBearer JWT + OrgRecent team activity feed
PATCH/v1/team/:userId/notificationsBearer JWT + OrgUpdate notification prefs

Org usage

MethodPathAuthDescription
GET/v1/org/usageBearer JWT + OrgPlan, limits, and current usage

Notifications

MethodPathAuthDescription
GET/v1/notificationsBearer JWT + OrgList notifications + unread count
POST/v1/notifications/:id/readBearer JWT + OrgMark one as read
POST/v1/notifications/read-allBearer JWT + OrgMark all as read

Integrations

MethodPathAuthDescription
GET/v1/integrationsBearer JWT + OrgList integrations for a project
POST/v1/integrationsBearer JWT + OrgCreate integration
PATCH/v1/integrations/:idBearer JWT + OrgUpdate integration
DELETE/v1/integrations/:idBearer JWT + OrgDelete integration
POST/v1/integrations/:id/testBearer JWT + OrgSend a test payload
GET/v1/integrations/:id/deliveriesBearer JWT + OrgDelivery history
POST/v1/integrations/:id/deliveries/:deliveryId/retryBearer JWT + OrgRetry a failed delivery

Admin (superadmin only)

MethodPathAuthDescription
GET/v1/admin/overviewSuperadminGlobal counts + 30-day growth
GET/v1/admin/organizationsSuperadminAll orgs with counts
GET/v1/admin/customersSuperadminCustomer-view orgs with filters
GET/v1/admin/customers/:orgIdSuperadminCustomer profile + team + projects
POST/v1/admin/customersSuperadminManually create a customer
POST/v1/admin/customers/:orgId/teamSuperadminAdd/promote a workspace member
PATCH/v1/admin/customers/:orgIdSuperadminUpdate plan / stage / billing / notes
GET/v1/admin/usersSuperadminList users + memberships
GET/v1/admin/waitlistSuperadminBrowse waitlist entries
POST/v1/admin/waitlist/:id/approveSuperadminApprove + send link
POST/v1/admin/waitlist/:id/rejectSuperadminReject
POST/v1/admin/waitlist/:id/resendSuperadminResend approval link
POST/v1/admin/support/sessionSuperadminStart 8-hour support session
GET/v1/admin/projectsSuperadminAll projects with org + counts
GET/v1/admin/api-keysSuperadminAll API keys with project + org
GET/v1/admin/activitySuperadminUnified recent actions
GET/v1/admin/db/tablesSuperadminRow counts for all tables
GET/v1/admin/db/tables/:tableSuperadminBrowse rows in an allowlisted table
GET/v1/admin/api-key-statsSuperadminAPI-key usage overview
GET/v1/admin/ai-usageSuperadminAI usage summary + breakdown

Agent notified

BugTape notifies the project's agent and hands over evidence. It does not fix code. The agent opens the PR; a human reviews it.

POST /v1/bugs/:id/notify-agent

"Send to agent". JWT (agent PATs refused), role admin, manager or member. Fires every agent channel for the bug's project and records one agent_notifications row per channel:

  • agent / webhook integrations subscribed to agent_notify — event agent_notify.
  • github — reuses the bug's existing GitHub issue delivery, else opens one through the normal issue path.
  • mcp — an active subscribe-scoped agent PAT that can read the project; stays queued until an MCP listen session collects it.

A repeat call within 60 s returns the earlier batch with 200 and reused: true. 201 otherwise.

{ "bugId": "…", "reused": false, "noChannel": false,
  "notifications": [ { "id": "…", "trigger": "manual", "channel": "agent", "channelName": "Claude runner",
                       "status": "delivered", "detail": null, "createdAt": "…", "deliveredAt": "…" } ] }

With no channel: one row { "channel": null, "status": "no_channel", "detail": "Connect an agent first" } and noChannel: true. Status is queued | delivered | failed | no_channel; a linked webhook delivery's live outcome wins over the stored row. Errors: 401, 403 (PAT or viewer/tester), 404 bug not in the org.

GET /v1/bugs/:id/agent-status

JWT, any member. The timeline Notified → Picked up → PR opened.

{ "bugId": "…", "state": "pr_opened",
  "notified": { "at": "…", "trigger": "auto", "channels": [ … ] },
  "pickedUp": { "at": "…", "by": "pat:bt_pat_ab12", "note": "on it" },
  "prOpened": { "at": "…", "url": "https://github.com/o/r/pull/7" },
  "canConnect": false, "history": [ … ] }

state is none | no_channel | notified | working | pr_opened. Picked up comes from agent_ack = 'working' and agent_ack_by.

POST /v1/bugs/:id/ack — prUrl

Optional prUrl (absolute https://, no credentials, at most 2048 characters) with action: "working"; 400 PR_URL_INVALID otherwise. Stored as bug_reports.agent_pr_url. The first working ack and each new prUrl send agent_update to the project's human destinations.

GET / PUT /v1/projects/:id/agent-settings

GET: JWT, any member. PUT: JWT, admin or manager; support sessions refused. Body { "autoNotify"?: boolean, "minSeverity"?: "critical" | "high" | "medium" | "low" } (at least one). Response { "settings": { "projectId", "autoNotify", "minSeverity" } }. Default off, high. When on, the capture outbox sends agent_notify for each new issue or regression at or above minSeverity; a failure is recorded as a failed row and never fails capture.

GET /v1/projects/:id/agent-notifications?after=

MCP listen feed. Agent PAT with subscribe (project grants honored) or JWT. Without after: MCP-channel handoffs still queued from the last 24 h. With after (the previous cursor): rows created after it. Returned queued rows become delivered. Response { "cursor": "…", "notifications": [ { "id", "bugId", "trigger", "createdAt", "title", "severity" } ] }.

Endpoints added since this document was last revised

Reconciled 2026-08-01 by diffing every router.<verb> registration in packages/api/src/routes/ against the headings above. The document had drifted badly: 84 endpoints documented against 106 in source, with whole feature areas — Alerts, Tickets, Billing, Portfolio — absent entirely, and one entry pointing at a path that 404s (see the correction under GET /v1/bugs/reporters/:email/bugs).

Auth column: JWT = Bearer JWT + X-BugTape-Org; JWT-only = bearer token, no org header; Superadmin = JWT plus an ADMIN_EMAILS address; Key = X-BugTape-Key; Agent = X-Agent-Token + X-BugTape-Org (local-only — AGENT_TOKEN is deliberately unset in production).

Alerts / escalations

MethodPathAuthPurpose
GET/v1/alerts/policyJWTPer-project alert policy: severity floor, excludes, quiet hours
PUT/v1/alerts/policyJWTUpsert that policy
GET/v1/alerts/overviewJWTEscalation overview for the project
GET/v1/alerts/digests?projectId=<uuid>&after=<batch-id>JWTCaller-only email batch history for the selected project
POST/v1/alerts/digests/:batchId/resolutionRecipient JWTRecord receipt or explicitly request another copy of an uncertain batch

Digest history returns batches, pendingEvents and nextCursor. Omit after for the first page. Pages contain at most50 batches, ordered by creation time then ID ascending. Pass the returned cursor unchanged; foreign cursors return404. Counts include only the selected project. No recipient address or token is returned. States are queued, sending, succeeded, retryable, permanent_failure, cancelled and outcome_unknown. Succeeded means provider acceptance, not inbox delivery. Unknown outcomes do not automatically resend. Instant email is not in this batch history.

Each batch includes generation, resolution, allowedActions (received, retry, reason) and batchProjectCount. A resolution is an immutable recipient decision, separate from the original provider outcome. received is shown as “Confirmed by you”; it does not change the provider state to succeeded.

Resolution accepts a closed body: projectId, UUID requestId, integer expectedGeneration (0–3), and action (received or retry). Retry requires acceptDuplicateRisk: true; received must omit it. Only the current recipient can act. Support sessions, PATs and capture keys cannot mutate these decisions. An identical nonce/body returns the same receipt with replayed: true. Changed nonce content or stale state returns409. On an unknown response or DIGEST_BUSY, retain and retry the exact request. Cancellation cannot undo a commit.

Retry authorizes the whole email batch, including events from other projects. It queues the same batch/intent IDs with a new generation, preserving cumulative attempts. Current email preferences, access, issue exclusions and quiet hours still apply. A queue receipt does not mean sent. Limits are three manual retries and eight cumulative send/preparation attempts. A recipient can still confirm receipt at the retry limit. Responses include only a safe decision receipt: id, batchId, projectId, requestId, action, generation, nextGeneration and createdAt. History and resolution responses use Cache-Control: no-store.

Tickets (Kanban)

MethodPathAuthPurpose
GET/v1/ticketsJWT or AgentList tickets for the org
GET/v1/tickets/:idJWT or AgentSingle ticket
POST/v1/ticketsJWT or AgentCreate
PATCH/v1/tickets/:idJWT or AgentUpdate fields
POST/v1/tickets/:id/moveJWT or AgentMove between columns
DELETE/v1/tickets/:idJWT or AgentDelete

Billing

MethodPathAuthPurpose
POST/v1/billing/create-checkout-sessionJWTStart a Stripe checkout
POST/v1/billing/portalJWTStripe customer-portal session
GET/v1/billing/subscriptionJWTCurrent subscription state
GET/v1/billing/budgetJWT (direct admin)Budget, card on file, month-to-date spend
PUT/v1/billing/budgetJWT (direct admin)Set monthly budget / auto top-up flag
POST/v1/billing/card-setup-sessionJWT (direct admin)Setup-mode Checkout: save a card, no charge (501 without Stripe)

Portfolio

MethodPathAuthPurpose
GET/v1/portfolio/overviewJWT-onlyCross-org rollup over every org the caller belongs to. Deliberately not org-scoped — isolation comes from the caller's own team_memberships, so no org header is read.
MethodPathAuthPurpose
GET/v1/bugs/groupsJWTSignature-grouped issue list (the console inbox)
GET/v1/bugs/groups/:groupKey/instancesJWTInstances within one group (projectId required when the key spans projects, else 409 GROUP_KEY_AMBIGUOUS)
GET/v1/bugs/searchJWTTrigram + vector search, fused by RRF. Honors deliveryFeed.
GET/v1/bugs/reporters/:email/bugsJWTBugs reported by one email

Auth — account lifecycle and GDPR

MethodPathAuthPurpose
POST/v1/auth/switch-orgJWT-onlyRe-issue tokens against another org the user belongs to
POST/v1/auth/data-exportJWT-onlyRequest a GDPR data export
POST/v1/auth/data-delete/requestJWT-onlyRequest account deletion
POST/v1/auth/data-delete/confirmTokenConfirm deletion via emailed token

Admin — maintenance

MethodPathAuthPurpose
POST/v1/admin/backfill-signaturesSuperadminRecompute signature_hash across existing bugs
POST/v1/admin/gdpr/delete-orgSuperadminHard-delete an org and all its data

Health

MethodPathAuthPurpose
GET/v1/health/deepNoneDB latency, migration drift, email/AI configuration, pg-boss job depths. Names, booleans and counts only — no secrets. 503 when the DB or migrations are unhealthy. HTTP 200 does not mean jobs.status === 'ok'.

Ingest aliases on the bugs router

POST /v1/bugs, /v1/bugs/presence and /v1/bugs/ai-summary are the same handlers as the /v1/ingest equivalents, reachable on the console mount and guarded by X-BugTape-Key. Clients should use the /v1/ingest paths; these exist because both live on one router.

Not an alias any more: /v1/ingest used to mount the entire bugs router, so every console route also answered under /v1/ingest/*. That was closed on 2026-08-01 — the ingest mount now carries only POST /v1/ingest, /v1/ingest/presence and /v1/ingest/ai-summary.

End-user attribution

Every occurrence carries a canonical userKey: the app's own user id (SDK identify() → ingest userId), else the reporter email, else an anonymous key derived from the hashed client IP. It is the same expression behind every "users affected" count, so rosters always add up.

GET /v1/bugs/:id/users

Auth: Bearer JWT + X-BugTape-Org (agent PATs with get_repro_context, suggest_fix or subscribe)

Who hit this bug — one row per end user, newest last-seen first. Query: limit (default 50, max 200), offset.

{
  "users": [
    {
      "userKey": "u_123", "kind": "user", "endUserId": "u_123", "email": "sara@acme.com",
      "firstSeen": "2026-09-01T10:00:00.000Z", "lastSeen": "2026-09-02T10:00:00.000Z",
      "occurrenceCount": 3, "platforms": ["ios", "web"], "lastRelease": "1.4.0",
      "lastEnvironment": "production", "lastOccurrenceId": "…"
    }
  ],
  "total": 1, "limit": 50, "offset": 0
}

kind is user | email | anonymous. GET /v1/bugs/:id/occurrences?userKey=<key> narrows the occurrence list (and its total) to that user.

GET /v1/end-users?q=&limit=

Prefix search over user ids and emails inside the org (q ≥ 2 chars). Returns { query, users: [{ userKey, kind, endUserId, email, lastSeen, occurrenceCount, bugCount }] }.

GET /v1/end-users/:userKey/timeline?projectId=&limit=&offset=

Everything one end user hit across every bug and project in the org, newest first. 404 when the key has no occurrences in this org.

{
  "identity": { "userKey": "u_123", "kind": "user", "endUserId": "u_123", "emails": ["sara@acme.com"],
                "firstSeen": "…", "lastSeen": "…", "occurrenceCount": 5, "bugCount": 3, "sessionCount": 2, "platforms": ["ios"] },
  "occurrences": [
    { "id": "…", "bug_id": "…", "project_id": "…", "project_name": "Shop", "title": "TypeError: …",
      "severity": "high", "status": "new", "signal_class": "issue", "platform": "ios",
      "release_version": "1.4.0", "environment": "production", "url": "app://checkout",
      "session_id": "…", "browser": null, "os": "iOS", "occurred_at": "…" }
  ],
  "total": 5, "limit": 100, "offset": 0
}

MCP: get_bug_users(bugId) and get_user_timeline(userKey) expose the same data to agents.

POST /v1/projects/:id/test-event

Auth: Bearer JWT + X-BugTape-Org (human sessions only)

Setup wizard proof. Body { "platform": "web" | "ios" | "android" | "react-native" | "flutter" | "server" } (default web). Mints one real occurrence through the normal ingest pipeline — grouping, credits, webhooks, SSE, agent queue — titled SetupCheckError: BugTape test event received from <Platform>, with the calling user as the affected end user. Returns the ingest 201 envelope plus platform.

POST /v1/alerts/preview

Console user authentication and organization membership required; agent PATs are not accepted. Body: projectId (UUID), eventType (new_bug, regression, recommended_bug or status_change), title (at most1,000 characters), severity (low/medium/high/critical), optional url (at most4,096) and userEmail (at most254). Unknown fields are rejected. This evaluates saved alert rules at checkedAt, without creating policies, captures, batches or deliveries and without calling connections. Auto-ignore rules apply earlier during capture.

The response contains projectGate, caller-only email disposition and up to100 connection decisions, plus totalDestinations. Connection configuration and recipient addresses are omitted. Email distinguishes immediate eligibility, digest retention and exclusion. Eligible connections are not credential tests. New-bug and regression connections return deferred during quiet hours when otherwise eligible. Digest exclusions remain not_scheduled. Quiet-hour status-change samples report capture_path_only: capture promotions use durable delivery, while manual/legacy changes can be suppressed. Membership and preferences are rechecked at real delivery.

Evidence capability discovery

GET /v1/evidence/capabilities accepts one current human JWT, scoped agent PAT, or capture key through the existing evidence authentication. The response is private/no-store. It returns schemaVersion:1, configured, formats (kind/contentType/maxBytes), and reason. Capture keys also receive their authenticated projectId; request parameters cannot select another owner. An unconfigured runtime returns false, an empty list, and storage_unavailable. Configuration is not proof of storage reachability, quota or transfer completion.

Current admitted formats are log/text/plain (1MiB) and replay/application/json (10MiB) when storage is configured. Image/audio are unavailable. The browser SDK disables built-in screenshot/voice actions until artifact delivery is implemented; custom onSubmit integrations own their media. Old queued media is retained under its original TTL and reports onQueueEvent state unavailable, reason media-unavailable; it is not acknowledged as delivered.

Occurrence file metadata

GET /v1/evidence/projects/:projectId/occurrences/:occurrenceId/artifacts?limit=20&offset=0 lists files for one occurrence. It returns { artifacts, total, limit, offset }, ordered by creation time then ID, ascending. The limit is1–100 and offset0–10000. Each item includes artifact/project/occurrence IDs, format, byte count, SHA-256, state, generation, creation/expiry times and the client redaction declaration. Storage keys, storage credentials and uploader IDs are never returned.

Current human membership or a PAT with get_repro_context and a current project grant is required. Capture keys cannot list or download files. Missing/foreign occurrences return404 after project authorization; unauthorized projects return403. Unconfigured storage returns503. A client redaction declaration is not independent proof that private information was removed.

The console previews ready PNG screenshots through bounded authenticated downloads and an exact byte/hash check. Pending, failed, expired and missing files are explicit. Logout, workspace/occurrence changes, expiry and unmount remove the old image. This does not enable built-in screenshot uploads or hosted image admission: hosted policy remains unchanged until storage and decoder qualification are complete.

Managed installation delivery receipt

Human admin/manager JWT sessions can enroll an application with POST /v1/applications/projects/:projectId (requestId, name, identifier), then an installation with POST /v1/applications/projects/:projectId/:applicationId/installations (requestId, optional label). Keep each request ID and body fixed across uncertain retries. Matching list routes return bounded cursor pages. Capture keys and agent PATs cannot enroll or read this registry.

GET /v1/applications/projects/:projectId/:applicationId/installations/:installationId/capture-status accepts only the scoped path; no query/body options. It checks current manager membership and returns a non-cacheable waiting or capture_received state with checkedAt and nullable firstCapture. A first capture contains captureId, occurrenceId, the current canonical bugId when evidence exists, acceptedAt, sdkName, sdkVersion, captureSource, platform and evidenceAvailability (available or deleted_or_expired). A failed/denied check must not become a waiting or successful state.

Only a newly accepted v1 capture with exact managed application/installation identity creates this immutable receipt. Supported combinations are browser @bugtape/sdk, platform web, source automatic or user_report; or bugtape-swift, framework native, runtime swift, source handled_error or user_report, and the paired platform/OS values ios/ios or other/macos. Swift receipts also retain osFamily, framework and runtime. The receipt commits with ingest. Fatal native capture is excluded. The browser Setup flow still verifies browser installation only; this API does not complete native Setup. Console samples, legacy NULL sources, another installation and old accepted-capture retries do not qualify. No client timestamp determines freshness, and no historical data is backfilled. Capture-key clients can assert these provenance fields, so a receipt proves accepted delivery rather than device or binary attestation. Evidence deletion preserves minimal receipt identifiers; installation/project deletion cascades them.

Native receipt creation additionally requires BUGTAPE_NATIVE_INSTALLATION_RECEIPTS_ENABLED=true in the server runtime. The default is off for staged deployment. Disabling it stops new native receipts but preserves truthful reads of existing receipts. Reports remain ingestible, and no receipt is created retroactively when the flag is enabled.