# REST API

> Authentication, errors, pagination and every endpoint of the Comity API, grouped by area.

Base URL: `https://api.comity.so`. All REST endpoints are under `/v1`, take and return JSON, and use the same objects as the [MCP tools](/docs/reference/mcp-tools).

## Authentication

```sh
curl https://api.comity.so/v1/me -H "Authorization: Bearer cmt_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
```

```json
{
  "user": { "id": "0199…", "email": "diego@acme.com", "name": "Diego" },
  "org": { "id": "0199…", "slug": "acme", "name": "Acme Inc", "data_mode": "full" },
  "role": "member",
  "token": { "id": "0199…", "name": "cli · diego-laptop", "kind": "pat", "scopes": ["hooks:evaluate", "mcp:tools", "…"] }
}
```

- A token belongs to one org; there is no org parameter.
- **Personal tokens** (`pat`) act as their user with that user's role. `comity login` creates one per machine. Owners and admins can mint more, including for another member (`user_email`), with `POST /v1/tokens`.
- **Service tokens** (`service`) have no user. Endpoints marked "personal token" below reject them with 403. They can't hold `hooks:evaluate` or `mcp:tools`.
- Each endpoint requires a [scope](/docs/admin/security#tokens). Some also require a role: "admin" below means owner or admin.

Invalid, expired or revoked tokens get `401` with `WWW-Authenticate: Bearer realm="comity"`. A personal token stops working when its user leaves the org.

The dashboard uses a session cookie with an `X-Comity-Org: <slug>` header and `X-Comity-CSRF: 1` on writes; cookie sessions can't call hooks or MCP. Use tokens for integrations.

## Errors

Errors are [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) `application/problem+json`:

```json
{
  "type": "urn:comity:problem:validation",
  "title": "Invalid request",
  "status": 400,
  "detail": "request failed validation",
  "errors": [{ "path": "title", "message": "String must contain at least 1 character(s)" }]
}
```

| `type` suffix | Status | When |
|---|---|---|
| `bad-request` | 400 | Malformed request |
| `validation` | 400 | Body or query failed validation; see `errors[]` |
| `unauthorized` | 401 | Missing, invalid, expired or revoked token |
| `plan_limit` | 402 | Plan limit reached (repos; developers in MCP) |
| `forbidden` | 403 | Missing scope or role |
| `not-found` | 404 | Unknown resource or route |
| `conflict` | 409 | State conflict (already exists, already closed, …) |
| `rate-limited` | 429 | Message or question rate limit; see `Retry-After` |
| `unsupported-protocol` | 400 | Hooks: `X-Comity-Protocol` is not `1` |
| `internal` | 500 | Server error |

Billing and sign-in endpoints add their own types (`billing_unavailable`, `already_subscribed`, `invalid_credentials`, …).

## Pagination

List endpoints take `limit` (default 50, max 200) and `cursor`, and return:

```json
{ "items": [], "next_cursor": "MTI" }
```

Pass `next_cursor` as `cursor` for the next page. `next_cursor` is `null` on the last page. Lists described as "unpaged" always return `next_cursor: null`. Comma-separated values work for multi-value filters (`status=open,acknowledged`).

## Rate limits

- Messages: 20 per minute and 200 per day per user.
- Questions: 10 per minute and 100 per day per user.

Exceeding a limit returns `429` with `Retry-After` in seconds.

## Selectors and references

- **Repo**: `owner/name` or a remote fingerprint such as `github.com/acme/api`.
- **Intent**: `INT-12` or `12`. **Conflict**: `CF-9` or `9`.

## Hooks

| Method | Path | Scope | Notes |
|---|---|---|---|
| POST | `/v1/hooks/evaluate` | `hooks:evaluate` | What `comity hook` calls on each agent event. Optional header `X-Comity-Protocol: 1`. See [Hook events](/docs/reference/hook-events). |

## Account

| Method | Path | Scope | Notes |
|---|---|---|---|
| GET | `/v1/me` | any | User, org (incl. `data_mode`), role, token |
| PATCH | `/v1/org` | `policy:write`, owner, personal token | `{data_mode: "full"\|"metadata_only"}`. Returns `{org, previous_data_mode, cleared_signatures}`. Switching to `metadata_only` clears stored signature text. See [Data modes](/docs/admin/security#data-modes). |
| GET | `/v1/usage` | `members:read` | Plan, limits, active developers and repos this month; per-developer list for admins |

## Repositories and projects

| Method | Path | Scope | Notes |
|---|---|---|---|
| GET | `/v1/repos` | `repos:read` | Unpaged |
| POST | `/v1/repos` | `repos:write`, admin | `{full_name, provider?: "github"\|"gitlab"\|"bitbucket"\|"generic", default_branch?: "main", remotes?: []}`. Repos are also registered automatically on the first hook event. 409 if it exists, 402 over the plan's repo limit. |
| GET | `/v1/projects` | `repos:read` | Unpaged |
| GET | `/v1/projects/:id` | `repos:read` | |
| POST | `/v1/projects` | `repos:write`, admin | `{name, repos: [selector]}` |
| POST | `/v1/projects/:id/repos` | `repos:write`, admin | `{repos: [selector]}` |
| DELETE | `/v1/projects/:id/repos/:repo` | `repos:write`, admin | `:repo` = repo id or URL-encoded `owner/name` |
| DELETE | `/v1/projects/:id` | `repos:write`, admin | 204 |

## Intents

| Method | Path | Scope | Notes |
|---|---|---|---|
| GET | `/v1/intents` | `intents:read` | `?repo=&owner=me\|<email>&status=live\|<statuses>&limit=&cursor=` |
| POST | `/v1/intents` | `intents:write` | Same body as [`intent_declare`](/docs/reference/mcp-tools#intent_declare). 201 `{intent, conflicts}` |
| GET | `/v1/intents/:ref` | `intents:read` | |
| PATCH | `/v1/intents/:ref` | `intents:write` | Owner or admin. Same fields as [`intent_update`](/docs/reference/mcp-tools#intent_update)'s `patch` plus `note`. Returns `{intent, conflicts}` |
| POST | `/v1/intents/:ref/close` | `intents:write` | `{outcome: "merged"\|"abandoned"\|"handoff", note?, handoff_to?}` |
| POST | `/v1/intents/:ref/handoff` | `intents:write` | Owner or admin. `{to: <member email>, note?}` (note up to 2,000). Returns `{intent, handoff}`; `handoff` = `{id, intent, from, to, note, thread_id, message_id, created_at, adopted_at}`. 400 if the recipient can't own intents (viewer, billing), 404 unknown member, 409 intent closed or already theirs. See [Handoffs](/docs/collaboration/handoffs). |

`status=live` means `draft`, `active`, `blocked`, `in_review`.

```sh
curl -X POST https://api.comity.so/v1/intents \
  -H "Authorization: Bearer $COMITY_TOKEN" -H "Content-Type: application/json" \
  -d '{"repo": "acme/api", "title": "Add invoice PDF export", "scope": {"paths": ["apps/api/src/invoices/**"]}}'
```

## Conflicts

| Method | Path | Scope | Notes |
|---|---|---|---|
| GET | `/v1/conflicts` | `conflicts:read` | `?status=live\|<statuses>&mine=true&repo=&intent=&limit=&cursor=`. `repo` also matches cross-repo conflicts where the repo is the dependent side. |
| GET | `/v1/conflicts/:ref` | `conflicts:read` | |
| POST | `/v1/conflicts/:ref/actions` | `conflicts:write` | Participant or admin. `{action, note?}`; actions as in [`conflict_respond`](/docs/reference/mcp-tools#conflict_respond). Header `X-Comity-Session: <uuid>` acts as one of your agent sessions. |
| POST | `/v1/conflicts/:ref/feedback` | `conflicts:write` | `{verdict: "useful"\|"false_positive"\|"too_late"}`. 204 |
| POST | `/v1/conflicts/:ref/override` | `conflicts:write` | `{reason}`: 3–2,000 characters, trimmed; optional when the policy sets `override.require_reason: false`. Role must be in the policy's `override.allowed_roles`. |

`status=live` means `open`, `acknowledged`, `resolving`.

## Messages and conversations

| Method | Path | Scope | Notes |
|---|---|---|---|
| GET | `/v1/messages` | `messages:read`, personal token | Your inbox. `?unread_only=true&limit=&cursor=`. Returned messages are marked read. |
| POST | `/v1/messages` | `messages:write` | `{to, body, subject?, requires_ack?, session_id?}`; targets as in [`message_send`](/docs/reference/mcp-tools#message_send). 201 |
| POST | `/v1/messages/:id/ack` | `messages:write` | `{id, acked_at}` |
| GET | `/v1/conversations` | `messages:read` | `?kind=channel\|direct&limit=(≤100)&cursor=` |
| GET | `/v1/conversations/:id` | `messages:read` | |
| GET | `/v1/conversations/:id/messages` | `messages:read` | `?limit=&before=<message id>`; oldest → newest; `next_cursor` is the `before` for the older page |
| POST | `/v1/conversations/:id/messages` | `messages:write` | `{body, requires_ack?}`. In a direct thread only participants may post. |

Channels are conversations with `kind: "channel"`: `#team` and one per repo. Reading conversations in the dashboard marks messages read without hiding them from your agents.

## Coordination context

| Method | Path | Scope | Notes |
|---|---|---|---|
| POST | `/v1/team-context` | `intents:read` | Body as [`team_context`](/docs/reference/mcp-tools#team_context) |
| POST | `/v1/scope-check` | `intents:read` | Body as [`scope_check`](/docs/reference/mcp-tools#scope_check). Writes nothing. |
| GET | `/v1/sessions` | `intents:read` | `?active=true&repo=&limit=&cursor=`. `{id, repo, user, tool, model, branch, status, intent, started_at, last_heartbeat_at}` |
| GET | `/v1/presence` | `intents:read`, personal token | `?repo=&session_id=&limit=(≤100)`. Teammates' live agents, never your own. |
| GET | `/v1/attention` | personal token; `conflicts:read` and/or `messages:read` | `?limit=(≤100, default 50)`. The [Needs you](/docs/collaboration/needs-a-human) queue for the caller: `{items, total, by_kind}`. Kinds the token can't read are skipped. |
| POST | `/v1/attention/dismiss` | personal token | `{ids: [item id]}` (≤ 100). Hides the items for you until they change. 204 |

## Team memory

| Method | Path | Scope | Notes |
|---|---|---|---|
| GET | `/v1/search` | personal token; read scope per kind | `?q=&kinds=&repo=&limit=(≤50, default 20)`. Kinds you can't read are skipped. |
| POST | `/v1/questions` | `messages:write` | Body as [`ask`](/docs/reference/mcp-tools#ask). 201 |
| POST | `/v1/questions/:id/answers` | `messages:write` | `{answer, session_id?}`. 201 |

## Policies

| Method | Path | Scope | Notes |
|---|---|---|---|
| GET | `/v1/policies` | `policy:read` | `?repo=`. `{repo_name, org, repo, effective}` |
| PUT | `/v1/policies` | `policy:write`, admin | `?repo=`. `{rules: {…}}` replaces that scope's stored rules. See [Policies](/docs/admin/policies). |

## Members, invites and tokens

| Method | Path | Scope | Notes |
|---|---|---|---|
| GET | `/v1/members` | `members:read` | Paged |
| POST | `/v1/members` | `members:write`, admin | `{email, name?, role?: "member"}`. Only owners add owners or admins. |
| DELETE | `/v1/members/:userId` | `members:write`, admin, personal token | Revokes their tokens. Only owners remove owners or admins. 204 |
| GET | `/v1/invites` | `members:read`, admin | Pending invites. Unpaged. |
| POST | `/v1/invites` | `members:write`, admin | `{role, email?}`. Returns `{id, url, role, email, email_sent, expires_at}`. Expires after 7 days. |
| DELETE | `/v1/invites/:id` | `members:write`, admin | The invite's `id`. 204 |
| GET | `/v1/tokens` | personal token | Admins see all, others their own. `{id, name, kind, prefix, user_id, scopes, created_at, last_used_at, expires_at, revoked_at}` |
| POST | `/v1/tokens` | `tokens:write`, admin, personal token | `{name, kind?: "pat"\|"service", user_email?, scopes?, expires_in_days? (≤3650)}`. The plaintext `token` is returned once. |
| DELETE | `/v1/tokens/:id` | own token, or admin | 204 |

```sh
# A service token for CI that reads conflicts
curl -X POST https://api.comity.so/v1/tokens \
  -H "Authorization: Bearer $COMITY_TOKEN" -H "Content-Type: application/json" \
  -d '{"name": "ci-conflict-report", "kind": "service", "scopes": ["conflicts:read", "intents:read"]}'
```

> [!NOTE]
> Creating tokens needs a token with `tokens:write`. Tokens from `comity login` don't have it (their scopes are listed under [Security & data](/docs/admin/security#tokens)), and the dashboard's Team tab lists and revokes tokens but doesn't create them.

## Events, stats and streaming

| Method | Path | Scope | Notes |
|---|---|---|---|
| GET | `/v1/events` | `events:read` | Audit log. `?type=<types>&subject=<uuid>&limit=&cursor=`, newest first |
| GET | `/v1/feed` | `events:read` | Same as events plus `refs` with display names |
| GET | `/v1/stats` | `events:read` | `?days=7\|30\|90&repo=`. Totals, time saved, daily series, heatmap, top clients/members/repos |
| GET | `/v1/stream` | `events:read` | Server-Sent Events. See below. |

Event envelope (same as [webhook payloads](/docs/admin/integrations#payload)):

```json
{
  "id": "18342",
  "type": "intent.declared",
  "version": 1,
  "occurred_at": "2026-10-19T12:00:00.000Z",
  "org_id": "0199…",
  "actor": { "kind": "session", "id": "0199…" },
  "subject": { "kind": "intent", "id": "0199…" },
  "payload": { "…": "…" }
}
```

### SSE stream

```sh
curl -N "https://api.comity.so/v1/stream?types=conflict.detected,hook.blocked&repos=acme/api" \
  -H "Authorization: Bearer $COMITY_TOKEN"
```

```text
retry: 3000

id: 18342
event: conflict.detected
data: {"id":"18342","type":"conflict.detected","version":1,…}

: ka
```

- Filters: `types` and `repos` (comma-separated).
- Resume with the `Last-Event-ID` header (or `?last_event_id=`): missed events are replayed.
- A `: ka` comment every 15 seconds keeps the connection alive.
- On server restart you get `event: reconnect` with `data: {"after_ms":1000}`.

## Integrations

Owner or admin with a personal token. See [Integrations](/docs/admin/integrations) and [Weekly digest](/docs/admin/digest).

| Method | Path | Notes |
|---|---|---|
| GET | `/v1/webhooks` | Unpaged |
| POST | `/v1/webhooks` | `{url, event_types?: ["*"], description?}`. 201 with `secret` (once) |
| DELETE | `/v1/webhooks/:id` | 204 |
| POST | `/v1/webhooks/:id/test` | 202 `{delivery_id}`; re-enables a disabled endpoint |
| GET | `/v1/webhooks/:id/deliveries` | `?limit=` |
| GET | `/v1/integrations/slack` | Connection status and last post |
| PUT | `/v1/integrations/slack` | `{webhook_url}`; `null` disconnects |
| POST | `/v1/integrations/slack/test` | 202 `{notification_id}` |
| GET | `/v1/integrations/digest` | Schedule, channels, `email_me`, recipients, `next_send_at`, `last_run` |
| PUT | `/v1/integrations/digest` | Any of `{enabled, day, hour, channels, email_me}` (UTC; `email_me` is the caller's opt-in) |
| POST | `/v1/integrations/digest/preview` | `{digest, slack: {text, blocks}, email: {subject, text, html}}`; sends nothing |
| POST | `/v1/integrations/digest/send-now` | The run. 409 nowhere to send, 429 within a minute of the last one |
| GET | `/v1/github/app` | Apps, installations, install links |
| GET | `/v1/github/app/manifest` | `?owner=&format=json\|html`. Starts the GitHub App manifest flow |

## Billing

| Method | Path | Who | Notes |
|---|---|---|---|
| GET | `/v1/billing` | owner, admin, billing | Plan and subscription |
| POST | `/v1/billing/checkout` | owner, billing | `{plan: "team"\|"business"}` → `{url}` |
| POST | `/v1/billing/portal` | owner, billing | → `{url}` |

## Health

| Method | Path | Notes |
|---|---|---|
| GET | `/healthz` | `{"status":"ok"}`. No auth. |
| GET | `/readyz` | `{"status":"ready"\|"unavailable","checks":{…}}`; 503 when not ready. No auth. |

## MCP

`GET|POST|DELETE /mcp`: the MCP Streamable HTTP endpoint, bearer token with `mcp:tools`. See [MCP tools](/docs/reference/mcp-tools).
