# API Reference

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

All endpoints are prefixed with `/v1/`.

## Authentication

The API uses these authentication modes:

| Mode | Header(s) | Used for |
|------|-----------|----------|
| `Bearer agent PAT` | `Authorization: Bearer bt_pat_…` | Explicit agent actions; work and evidence require current project grants as stated below |
| `none` | — | Public unauthenticated endpoints (waitlist, login, reset, etc.) |
| `X-BugTape-Key` | `X-BugTape-Key: bt_live_*` | SDK ingestion (`POST /v1/ingest`, `POST /v1/ingest/presence`, `POST /v1/ingest/ai-summary`) |
| `Bearer JWT` | `Authorization: Bearer <token>` | User-scoped endpoints that do not need an org (e.g. `/v1/auth/me`) |
| `Bearer JWT + X-BugTape-Org` | `Authorization: Bearer <token>` + `X-BugTape-Org: <uuid>` | All workspace/management endpoints |
| `Bearer JWT + Superadmin` | `Authorization: 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](./authentication.md) 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](./runner-verification.md). 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 path | Required PAT action | Additional input / behavior |
|---|---|---|
| POST `/claims` | `claim_issue` | Optional `ttlSeconds`:60–900, default300. Returns the current fenced lease. |
| POST `/claims/renew` | `claim_issue` | Lease fields; optional TTL. Expired or stale leases cannot be renewed. |
| POST `/claims/release` | `claim_issue` | Lease fields. Release unfinished work. |
| POST `/claims/complete` | `claim_issue` | Lease fields plus `receiptId`. Closes work; does not verify the issue. |
| GET `/claims` | `claim_issue` or `request_evidence` | Query `projectId`, `issueId`; returns `{claim}`. |
| POST `/verification/challenges` | `submit_verification` | Lease fields. Same PAT must submit the signed attempt. |
| POST `/verification/receipts` | `submit_verification` | Exact measured submission and optional runner proof. Unsigned attempts remain pending. |
| GET `/verification/receipts/:receiptId` | `read_verification` | Query `projectId`, `issueId`; inspect status, trust and current applicability. |
| GET `/verification/receipts/:receiptId/evidence` | `request_evidence` | Same query; metadata only, `contentAvailable:false`. |
| POST `/verification/promote` | `submit_verification` plus current claim authority | Live 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 path | Input |
|---|---|
| POST `/runners` | `commandId`, `projectId`, `name`, Ed25519 `publicKeyPem`, `trustLevel` (`ci` or `device_lab`), `allowedRuntimes`. Never send a private key. |
| POST `/devices` | `commandId`, `projectId`, `name`, `runnerId`, `applicationId`, `installationId`, `runtime`, `deviceIdentifierSha256`, `osVersion`; physical iOS requires explicit `physicalDeviceConfirmed:true`. |
| GET `/enrollment` | Query `projectId`; public enrollment metadata only. |
| POST `/runners/revoke` | `commandId`, `projectId`, `runnerId`. |
| POST `/devices/revoke` | `commandId`, `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

| Scope | Limit | Window |
|-------|-------|--------|
| General API | 200 requests | 60 seconds per IP |
| Ingestion | 100 requests | 60 seconds per IP |
| Auth (login/register/reset) | 10 requests | 60 seconds per IP |

Rate-limited responses return `429`:

```json
{
  "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:

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

Malformed JSON bodies return:

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

Plan-limit violations return HTTP `402`:

```json
{
  "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):**

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

**Degraded (503):**

```json
{
  "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:**

```json
{
  "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:**

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

**Success responses:**

```json
{ "success": true, "status": "pending", "message": "You have been added to the BugTape waitlist." }
```

```json
{ "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):**

```json
{
  "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:**

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

**Success (201):**

```json
{
  "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:**

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

**Success (200):**

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

**Error codes:**

| Status | Body | Cause |
|--------|------|-------|
| 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:**

```json
{ "refreshToken": "eyJ..." }
```

**Success (200):**

```json
{ "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):**

```json
{
  "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:**

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

**Success (200):**

```json
{ "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:**

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

**Success (200):**

```json
{ "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):**

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

Or, if already verified:

```json
{ "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:**

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

**Success (200):**

```json
{
  "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:**

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

**Success (200):**

```json
{ "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):**

```json
{
  "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](./ingest-schema.md).

#### Request headers

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

#### Request body

```typescript
{
  // 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

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

#### Success response (201)

```json
{
  "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "occurrenceId": "b2c3d4e5-f6a7-8901-bcde-f23456789012",
  "fingerprint": "abc123def456gh78",
  "deduplicated": false,
  "regression": false,
  "is_regression": false
}
```

| Field | Type | Description |
|-------|------|-------------|
| `id` | `string` | UUID — stable grouped-bug identifier. |
| `occurrenceId` | `string` | UUID — this specific occurrence under the grouped bug. |
| `fingerprint` | `string` | 16-character hex; reports with the same fingerprint are grouped. |
| `deduplicated` | `boolean` | `true` if this report matched an existing open bug. |
| `regression` | `boolean` | `true` if this report matched a previously resolved bug (reopened). |
| `is_regression` | `boolean` | Alias for `regression`. |

#### Error responses

| Status | Body | Cause |
|--------|------|-------|
| 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` |
| 402 | See plan-limit envelope above | Monthly bug limit exceeded |
| 429 | See rate-limit envelope above | Rate 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:

- `groupingV3` — `null`, 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:**

```typescript
{
  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):**

```json
{ "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:**

```typescript
{
  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):**

```json
{
  "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:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| `projectId` | `uuid` | — | Filter by project |
| `status` | `string` | — | `new`, `known`, `working`, `resolved`, `silenced` |
| `severity` | `string` | — | `critical`, `high`, `medium`, `low` |
| `signalClass` | `string` | — | `issue`, `watch`, `diagnostic`, or `all` |
| `includeDiagnostics` | `bool` | `false` | Include diagnostic signals when `signalClass` is not set |
| `search` | `string` | — | Full-text `ILIKE` on title and description |
| `limit` | `number` | `50` | Page size (max 200) |
| `offset` | `number` | `0` | Pagination offset |

**Success (200):**

```json
{
  "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):**

```json
{
  "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):**

```json
{
  "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):**

```json
{
  "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:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| `format` | `json` / `csv` | `json` | Output format |
| `status` | string | — | Comma-separated (`new,known`) |
| `severity` | string | — | Comma-separated (`critical,high`) |
| `projectId` | uuid | — | Restrict to one project |
| `deliveryFeed` | `all` / `urgent_critical` / `ui_ux` | `all` (humans) | Facet feed; PATs use stored PAT then project |
| `limit` | number | `10000` | Max rows (hard cap `50000`) |
| `after` | ISO 8601 | — | Cursor — only bugs with `last_seen > after` |

**JSON success (200):**

```json
{
  "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):**

```json
{
  "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):**

```json
{
  "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):**

```json
{
  "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):**

```json
{
  "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):**

```json
{ "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):**

```json
{
  "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):**

```json
{
  "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):**

```json
{
  "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:**

```json
{
  "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):**

```json
{ "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:**

```json
{
  "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):**

```json
{ "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:**

```json
{
  "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):**

```json
{ "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:**

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

**Success (201):**

```json
{
  "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:**

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

**Success (200):**

```json
{ "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):**

```json
{ "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):**

```json
{
  "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):**

```json
{ "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):**

```json
{
  "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:**

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

`orgId` must match the caller's org.

**Success (201):**

```json
{ "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:**

```json
{ "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):**

```json
{
  "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):**

```json
{
  "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):**

```json
{ "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):**

```json
{
  "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:**

```json
{
  "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):**

```json
{ "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:**

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

**Success (200):**

```json
{
  "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):**

```json
{ "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):**

```json
{
  "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):**

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

**Success (200):**

```json
{ "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):**

```json
{
  "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):**

```json
{
  "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:**

```json
{
  "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):**

```json
{
  "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:**

```json
{
  "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):**

```json
{
  "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.

```json
{
  "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](./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):**

```json
{
  "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):**

```json
{
  "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):**

```json
{
  "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):**

```json
{
  "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:**

```json
{
  "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):**

```json
{
  "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:**

```json
{
  "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):**

```json
{
  "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):**

```json
{
  "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):**

```json
{
  "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):**

```json
{ "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):**

```json
{
  "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):**

```json
{
  "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):**

```json
{
  "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

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET    | `/v1/health` | none | Process + cheap DB ping (Fly liveness; not a ship signal) |
| GET    | `/v1/health/deep` | none | DB, migrations, jobs/DLQ, redis. HTTP 200 can still mean `jobs: degraded` |

### Auth

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST   | `/v1/auth/register` | none | Open self-serve signup; creates user and workspace |
| POST   | `/v1/auth/waitlist` | none | Join the signup waitlist |
| GET    | `/v1/auth/approval/:token` | none | Validate a waitlist approval token |
| POST   | `/v1/auth/complete-signup` | none | Consume approval token, create account + tokens |
| POST   | `/v1/auth/login` | none | Password login → access + refresh tokens |
| POST   | `/v1/auth/refresh` | none | Swap refresh token for a fresh access token |
| GET    | `/v1/auth/me` | Bearer JWT | Current user profile + org memberships |
| GET    | `/v1/auth/whats-new` | Bearer JWT + Org | Activity since the user's last login |
| POST   | `/v1/auth/send-verification` | Bearer JWT | Send verification email to current user |
| POST   | `/v1/auth/resend-verification` | none | Public resend (enumeration-safe) |
| POST   | `/v1/auth/verify-email` | none | Consume an email-verification token |
| POST   | `/v1/auth/forgot-password` | none | Request a password-reset link |
| POST   | `/v1/auth/reset-password` | none | Consume reset token + set new password |

### Ingestion

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST   | `/v1/ingest` | X-BugTape-Key | Submit a bug report |
| POST   | `/v1/ingest/presence` | X-BugTape-Key | Live-presence beacon |
| POST   | `/v1/ingest/ai-summary` | X-BugTape-Key | Server-side AI summary for a pending report |

### Bugs (read)

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET    | `/v1/bugs` | Bearer JWT + Org | List grouped bugs |
| GET    | `/v1/bugs/stats` | Bearer JWT + Org | Aggregates, MTTR, health score |
| GET    | `/v1/bugs/live` | Bearer JWT + Org | Recent occurrences across the org |
| GET    | `/v1/bugs/presence` | Bearer JWT + Org | Active visitor snapshot |
| GET    | `/v1/bugs/export` | Bearer JWT + Org | Export bugs (JSON or CSV) |
| GET    | `/v1/bugs/:id` | Bearer JWT + Org | Single bug with reporters + recent occurrences |
| GET    | `/v1/bugs/:id/occurrences` | Bearer JWT + Org | Occurrences under a bug |
| GET    | `/v1/bugs/:id/events` | Bearer JWT + Org | Events for a bug or occurrence |
| GET    | `/v1/bugs/:id/history` | Bearer JWT + Org | Status-change audit trail |
| GET    | `/v1/bugs/:id/comments` | Bearer JWT + Org | List comments |
| GET    | `/v1/bugs/:id/watching` | Bearer JWT + Org | Is current user watching? |
| GET    | `/v1/bugs/:id/similar` | Bearer JWT + Org | Similar-bugs scoring |
| GET    | `/v1/bugs/:id/anomalies` | Bearer JWT + Org | Heuristic anomaly detection |
| GET    | `/v1/bugs/stream` | Bearer JWT + Org | SSE real-time stream |
| GET    | `/v1/reporters/:email/bugs` | Bearer JWT + Org | All bugs by reporter email |

### Bugs (write)

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST   | `/v1/bugs/manual` | Bearer JWT + Org | Create a bug from the console |
| PATCH  | `/v1/bugs/:id` | Bearer JWT + Org | Update status / severity / assignment |
| PATCH  | `/v1/bugs/bulk` | Bearer JWT + Org | Bulk update up to 100 bugs |
| POST   | `/v1/bugs/:id/comments` | Bearer JWT + Org | Add comment (auto-subscribe, @mentions) |
| POST   | `/v1/bugs/:id/watch` | Bearer JWT + Org | Watch a bug |
| DELETE | `/v1/bugs/:id/watch` | Bearer JWT + Org | Unwatch a bug |
| POST   | `/v1/bugs/merge` | Bearer JWT + Org | Merge sources into a target |
| DELETE | `/v1/bugs/:id` | Bearer JWT + Org | Permanently delete a bug + cascades |
| POST   | `/v1/bugs/:id/analyze` | Bearer JWT + Org | Deep AI analysis of a bug |
| POST   | `/v1/bugs/:id/events/:eventId/ai` | Bearer JWT + Org | AI explain/fix for an event |

### Projects & API keys

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET    | `/v1/projects` | Bearer JWT + Org | List projects |
| POST   | `/v1/projects` | Bearer JWT + Org | Create a project |
| POST   | `/v1/projects/:id/keys` | Bearer JWT + Org | Generate an API key |
| GET    | `/v1/projects/:id/keys` | Bearer JWT + Org | List API keys (masked) |
| DELETE | `/v1/projects/:id/keys/:keyId` | Bearer JWT + Org | Revoke an API key (soft-delete) |

### Team

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET    | `/v1/team` | Bearer JWT + Org | List team members |
| POST   | `/v1/team/invite` | Bearer JWT + Org | Invite by email |
| PATCH  | `/v1/team/:userId` | Bearer JWT + Org | Change role |
| DELETE | `/v1/team/:userId` | Bearer JWT + Org | Remove from org |
| GET    | `/v1/team/activity` | Bearer JWT + Org | Recent team activity feed |
| PATCH  | `/v1/team/:userId/notifications` | Bearer JWT + Org | Update notification prefs |

### Org usage

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET    | `/v1/org/usage` | Bearer JWT + Org | Plan, limits, and current usage |

### Notifications

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET    | `/v1/notifications` | Bearer JWT + Org | List notifications + unread count |
| POST   | `/v1/notifications/:id/read` | Bearer JWT + Org | Mark one as read |
| POST   | `/v1/notifications/read-all` | Bearer JWT + Org | Mark all as read |

### Integrations

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET    | `/v1/integrations` | Bearer JWT + Org | List integrations for a project |
| POST   | `/v1/integrations` | Bearer JWT + Org | Create integration |
| PATCH  | `/v1/integrations/:id` | Bearer JWT + Org | Update integration |
| DELETE | `/v1/integrations/:id` | Bearer JWT + Org | Delete integration |
| POST   | `/v1/integrations/:id/test` | Bearer JWT + Org | Send a test payload |
| GET    | `/v1/integrations/:id/deliveries` | Bearer JWT + Org | Delivery history |
| POST   | `/v1/integrations/:id/deliveries/:deliveryId/retry` | Bearer JWT + Org | Retry a failed delivery |

### Admin (superadmin only)

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET    | `/v1/admin/overview` | Superadmin | Global counts + 30-day growth |
| GET    | `/v1/admin/organizations` | Superadmin | All orgs with counts |
| GET    | `/v1/admin/customers` | Superadmin | Customer-view orgs with filters |
| GET    | `/v1/admin/customers/:orgId` | Superadmin | Customer profile + team + projects |
| POST   | `/v1/admin/customers` | Superadmin | Manually create a customer |
| POST   | `/v1/admin/customers/:orgId/team` | Superadmin | Add/promote a workspace member |
| PATCH  | `/v1/admin/customers/:orgId` | Superadmin | Update plan / stage / billing / notes |
| GET    | `/v1/admin/users` | Superadmin | List users + memberships |
| GET    | `/v1/admin/waitlist` | Superadmin | Browse waitlist entries |
| POST   | `/v1/admin/waitlist/:id/approve` | Superadmin | Approve + send link |
| POST   | `/v1/admin/waitlist/:id/reject` | Superadmin | Reject |
| POST   | `/v1/admin/waitlist/:id/resend` | Superadmin | Resend approval link |
| POST   | `/v1/admin/support/session` | Superadmin | Start 8-hour support session |
| GET    | `/v1/admin/projects` | Superadmin | All projects with org + counts |
| GET    | `/v1/admin/api-keys` | Superadmin | All API keys with project + org |
| GET    | `/v1/admin/activity` | Superadmin | Unified recent actions |
| GET    | `/v1/admin/db/tables` | Superadmin | Row counts for all tables |
| GET    | `/v1/admin/db/tables/:table` | Superadmin | Browse rows in an allowlisted table |
| GET    | `/v1/admin/api-key-stats` | Superadmin | API-key usage overview |
| GET    | `/v1/admin/ai-usage` | Superadmin | AI 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.

```json
{ "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.

```json
{ "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

| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | `/v1/alerts/policy` | JWT | Per-project alert policy: severity floor, excludes, quiet hours |
| PUT | `/v1/alerts/policy` | JWT | Upsert that policy |
| GET | `/v1/alerts/overview` | JWT | Escalation overview for the project |
| GET | `/v1/alerts/digests?projectId=<uuid>&after=<batch-id>` | JWT | Caller-only email batch history for the selected project |
| POST | `/v1/alerts/digests/:batchId/resolution` | Recipient JWT | Record 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)

| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | `/v1/tickets` | JWT or Agent | List tickets for the org |
| GET | `/v1/tickets/:id` | JWT or Agent | Single ticket |
| POST | `/v1/tickets` | JWT or Agent | Create |
| PATCH | `/v1/tickets/:id` | JWT or Agent | Update fields |
| POST | `/v1/tickets/:id/move` | JWT or Agent | Move between columns |
| DELETE | `/v1/tickets/:id` | JWT or Agent | Delete |

### Billing

| Method | Path | Auth | Purpose |
|---|---|---|---|
| POST | `/v1/billing/create-checkout-session` | JWT | Start a Stripe checkout |
| POST | `/v1/billing/portal` | JWT | Stripe customer-portal session |
| GET | `/v1/billing/subscription` | JWT | Current subscription state |
| GET | `/v1/billing/budget` | JWT (direct admin) | Budget, card on file, month-to-date spend |
| PUT | `/v1/billing/budget` | JWT (direct admin) | Set monthly budget / auto top-up flag |
| POST | `/v1/billing/card-setup-session` | JWT (direct admin) | Setup-mode Checkout: save a card, no charge (501 without Stripe) |

### Portfolio

| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | `/v1/portfolio/overview` | JWT-only | Cross-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. |

### Bugs — grouping and search

| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | `/v1/bugs/groups` | JWT | Signature-grouped issue list (the console inbox) |
| GET | `/v1/bugs/groups/:groupKey/instances` | JWT | Instances within one group (`projectId` required when the key spans projects, else `409 GROUP_KEY_AMBIGUOUS`) |
| GET | `/v1/bugs/search` | JWT | Trigram + vector search, fused by RRF. Honors `deliveryFeed`. |
| GET | `/v1/bugs/reporters/:email/bugs` | JWT | Bugs reported by one email |

### Auth — account lifecycle and GDPR

| Method | Path | Auth | Purpose |
|---|---|---|---|
| POST | `/v1/auth/switch-org` | JWT-only | Re-issue tokens against another org the user belongs to |
| POST | `/v1/auth/data-export` | JWT-only | Request a GDPR data export |
| POST | `/v1/auth/data-delete/request` | JWT-only | Request account deletion |
| POST | `/v1/auth/data-delete/confirm` | Token | Confirm deletion via emailed token |

### Admin — maintenance

| Method | Path | Auth | Purpose |
|---|---|---|---|
| POST | `/v1/admin/backfill-signatures` | Superadmin | Recompute `signature_hash` across existing bugs |
| POST | `/v1/admin/gdpr/delete-org` | Superadmin | Hard-delete an org and all its data |

### Health

| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | `/v1/health/deep` | None | DB 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`.

```json
{
  "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.

```json
{
  "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.
