Authentication
For agents: you authenticate with
Authorization: Bearer bwt_…;whoami()tells you which organization the token belongs to and its scopes. Errors:401bad or expired token,403missing scope,404unknown org/project — including organizations the token cannot see.
Two principals
API tokens — Authorization: Bearer bwt_…. Bound to one organization, with an explicit scope list and optional expiry. Stored as SHA-256; shown once at creation. Used by scripts, CI, sentry-cli (SENTRY_AUTH_TOKEN), and MCP clients. See Tokens & scopes.
Sessions — the bugwatch_session cookie the dashboard sets after sign-in (HttpOnly, Secure, SameSite=Lax, 30 days; only its SHA-256 is stored). A session user's scopes for an organization are derived from their membership role on every request. The API allows the dashboard origin with credentials, so the cookie works cross-subdomain from app.bugwatch.io.
Cross-site requests. A state-changing request that carries the session cookie, and every /auth/* POST, must come from the dashboard's origin (or the API's own host) and, if it has a body, send application/json. Anything else is refused: 403 for a foreign Origin, 415 for another body type. Bearer-token requests are not affected, because a token is never sent by a browser on its own.
Rate limits. Sign-in, signup, magic links, /oauth/token, /oauth/register and status-page subscribe are limited per client IP (20 a minute per route), and password sign-in also per account (5 a minute), whatever IP the attempts come from. One address gets at most 5 sign-in or confirmation emails an hour. Over a limit the answer is 429 with Retry-After.
Every /v1/* and /api/0/* route accepts either. /auth/* routes handle sign-in and need neither. /stripe/webhook authenticates by Stripe signature.
Sign-in routes
| Route | Purpose |
|---|---|
POST /auth/signup |
{email, password (≥10), name?, orgName, orgSlug} → creates user + org (owner). With an email provider configured: 202 {verifyEmail: true} and a confirmation link is emailed, with no cookie until the link is followed (the same answer whether or not the address already had an account). Without one: 201 and sets the cookie |
POST /auth/login |
{email, password}; uniform error and timing on unknown emails |
POST /auth/logout |
Deletes the session |
GET /auth/me |
Current user and their org memberships |
GET /auth/providers |
Which of google, github, microsoft, magicLink are enabled on this deployment |
GET /auth/oauth/{provider}/start?next=/path |
Begin OAuth; callback is GET /auth/oauth/{provider}/callback |
POST /auth/magic → POST /auth/magic/verify |
Email magic link (needs the email provider). Each link works once. /auth/login answers 403 {code: "email_unverified"} for an unconfirmed password account and re-sends its confirmation link |
POST /auth/orgs |
Create a first organization after social/magic sign-in |
Social identities are linked by (provider, subject); an email match links to an existing account only when the provider reports it verified.
Organization scoping
Every resource route is under /v1/orgs/{org}/…. The handler resolves the slug, then checks:
- A token must belong to that organization; a session user must be a member. Otherwise
404— cross-org requests never reveal that the org exists. - The required scope must be satisfied (
hasScope: exact match,org:adminimplies all,:writeimplies:read). Otherwise403 {"error": "insufficient scope"}.
Projects are addressed by slug within the org; unknown projects, issues, keys, or rules are 404.
Status codes
| Code | Meaning |
|---|---|
401 |
No credentials, invalid token, expired token, or expired session |
403 |
Authenticated but missing the scope |
404 |
Org not accessible, or resource not found |
400 |
Validation failure; body is {"error": {"formErrors": [], "fieldErrors": {…}}} (Zod) |
402 |
Plan limit (adding a billable member beyond the plan's seats) |
409 |
Slug already taken |
413 |
Upload too large |
501 |
Feature not configured on this deployment (Stripe, an OAuth provider, email) |
Example
curl -sS https://api.bugwatch.io/v1/whoami -H 'Authorization: Bearer bwt_…'
# {"via":"token","tokenName":"claude-code","user":{"id":"01J…","email":"you@example.com","name":null},
# "orgs":[{"slug":"acme","name":"Acme","scopes":["org:read","project:read","event:read"]}]}
Ingest uses a different credential entirely — the DSN public key in X-Sentry-Auth — and can only send events; see SDKs overview.