# Integrations

> Post to Slack, send signed webhooks to your services, and add the comity/coordination check to pull requests with the GitHub App.

Integrations are set up by owners and admins in the dashboard under **Integrations**. Other roles see them read-only. Linear, Jira and Discord are listed as "Coming soon"; they don't exist yet. The [weekly digest](/docs/admin/digest), a summary for whoever runs the team, is set up on the same tab.

## Slack

Comity posts to one Slack channel through a Slack **incoming webhook**. There is no Slack app to install, no OAuth and no slash commands.

### Connect

1. In Slack, create an incoming webhook and pick the channel.
2. In Comity, open **Integrations → Slack → Connect**, paste the URL (`https://hooks.slack.com/services/T…/B…/…`) and click **Connect**.
3. Click **Send test message**. The channel gets "Comity is connected".

Comity stores the URL encrypted and never shows it again. **Replace URL** swaps it; **Disconnect** removes it.

API equivalent (owner/admin personal token):

```sh
curl -X PUT https://api.comity.so/v1/integrations/slack \
  -H "Authorization: Bearer $COMITY_TOKEN" -H "Content-Type: application/json" \
  -d '{"webhook_url": "https://hooks.slack.com/services/T000/B000/XXXX"}'
```

`{"webhook_url": null}` disconnects. `GET /v1/integrations/slack` shows `connected`, `url_host`, `failing_since` and the `last_post` (status `pending`, `sending`, `sent`, `failed` or `superseded`). `POST /v1/integrations/slack/test` sends the test message.

### What gets posted

Controlled by the [`notify`](/docs/admin/policies#notify) policy (in the Slack drawer under **What gets posted**, or **Settings → Coordination policy → Slack notifications**):

- **New or escalated conflicts** at or above the minimum severity. By default only blocking conflicts (`conflict.detected:block`).
- **Agent action blocked** (`hook.blocked`), titled "Agent blocked in `<repo>`".
- **Resolved, overridden, went stale** (`conflict.resolved`, `conflict.overridden`, `conflict.stale`): posted when selected, and always for a conflict the channel was already told about, to close the loop.

Each post shows the repo, severity, subject, the two sides and the suggested next step. Updates to the same conflict are batched: at most one post per conflict per minute, later updates fold into it ("N more updates collapsed"). Failed posts (429, 5xx, network errors) retry with backoff for 24 hours.

## Webhooks

Webhooks send signed JSON events to your own services: alerting, dashboards, a SIEM, automations. An org can have up to 20 endpoints.

### Create an endpoint

**Integrations → Webhooks → Add endpoint**: enter an HTTPS URL, an optional description, and either **All events** or **Choose events**. After **Create endpoint**, copy the **signing secret** (`whsec_…`): it is shown only once. To rotate it, create a new endpoint and delete the old one.

```sh
curl -X POST https://api.comity.so/v1/webhooks \
  -H "Authorization: Bearer $COMITY_TOKEN" -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/comity/events", "event_types": ["conflict.detected", "conflict.resolved", "hook.blocked"], "description": "on-call alerts"}'
```

```json
{
  "id": "0199c3d1-…",
  "url_host": "example.com",
  "event_types": ["conflict.detected", "conflict.resolved", "hook.blocked"],
  "description": "on-call alerts",
  "active": true,
  "disabled_reason": null,
  "failing_since": null,
  "last_success_at": null,
  "created_at": "2026-10-19T10:02:11.000Z",
  "secret": "whsec_…"
}
```

`event_types` takes up to 64 event types, or `["*"]` (the default) for every type except the noisy `conflict.updated`, including types added later. Other routes: `GET /v1/webhooks`, `DELETE /v1/webhooks/:id`, `POST /v1/webhooks/:id/test`, `GET /v1/webhooks/:id/deliveries`.

URLs must be HTTPS and resolve to a public address; private, loopback and internal addresses are rejected. Redirects are not followed.

### Event types

| Group | Types |
|---|---|
| Conflicts | `conflict.detected`, `conflict.updated`, `conflict.acknowledged`, `conflict.resolved`, `conflict.overridden`, `conflict.reopened`, `conflict.stale` |
| Hooks | `hook.blocked`, `hook.warned` |
| Intents & claims | `intent.declared`, `intent.updated`, `intent.status_changed`, `intent.closed`, `intent.handed_off`, `claim.added`, `claim.released`, `claim.expired`, `snapshot.recorded` |
| Sessions | `session.started`, `session.ended`, `session.expired`, `session.status_shared` |
| Messages | `message.sent`, `message.acked`, `question.asked`, `question.answered` |
| Members | `member.added`, `member.removed`, `member.role_changed`, `invite.created`, `invite.accepted`, `invite.revoked`, `token.created`, `token.revoked` |
| Repos & projects | `repo.created`, `project.created`, `project.updated`, `project.deleted` |
| Org | `org.created`, `org.data_mode_changed`, `policy.updated`, `integration.connected`, `integration.removed`, `integration.connect_rejected`, `billing.updated`, `billing.payment_failed` |

### Payload

Every delivery is a POST of one event envelope. Event payloads carry identifiers, counts and enums, never message text, code or secrets.

```json
{
  "id": "18342",
  "type": "conflict.detected",
  "version": 1,
  "occurred_at": "2026-10-19T12:00:00.000Z",
  "org_id": "0199a0b2-…",
  "actor": { "kind": "session", "id": "0199c3a4-…" },
  "subject": { "kind": "conflict", "id": "0199c3e0-…" },
  "payload": { "repo_id": "0199a0c7-…", "number": 37, "kind": "contract_break", "severity": "warn", "confidence": 0.9, "a": "…", "b": "…" }
}
```

`actor.kind` is `user`, `session`, `system` or `token`. A test delivery has type `webhook.test` and an empty payload.

### Headers

| Header | Value |
|---|---|
| `X-Comity-Signature` | `t=<unix seconds>,v1=<hex HMAC-SHA256>` |
| `X-Comity-Event` | The event type |
| `X-Comity-Delivery` | Delivery id, the same across retries; use it to deduplicate |
| `Content-Type` | `application/json` |
| `User-Agent` | `Comity-Webhooks/1` |

### Verify the signature

The signature is a lowercase hex HMAC-SHA256 of `"<t>.<raw request body>"`, keyed with the full `whsec_…` secret. Verify against the raw bytes before parsing JSON, compare in constant time, and reject timestamps older than 5 minutes.

```ts
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyComity(rawBody: string, header: string | undefined, secret: string, toleranceSec = 300): boolean {
  if (!header) return false;
  const parts = header.split(",").map((p) => p.trim());
  const t = Number(parts.find((p) => p.startsWith("t="))?.slice(2));
  if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
  const expected = Buffer.from(createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex"));
  return parts
    .filter((p) => p.startsWith("v1="))
    .some((p) => {
      const got = Buffer.from(p.slice(3));
      return got.length === expected.length && timingSafeEqual(got, expected);
    });
}
```

```ts
// Express: keep the raw body for verification
app.post("/comity/events", express.raw({ type: "application/json" }), (req, res) => {
  const raw = req.body.toString("utf8");
  if (!verifyComity(raw, req.header("X-Comity-Signature"), process.env.COMITY_WEBHOOK_SECRET!)) return res.sendStatus(401);
  const event = JSON.parse(raw);
  // handle event.type …
  res.sendStatus(204);
});
```

### Delivery and retries

- Any 2xx response is success. Respond within a few seconds: the connection timeout is 5 s and the total timeout 10 s.
- Failures retry with exponential backoff (30 s, doubling, capped at 2 h, with jitter) for 24 hours, then the delivery is marked `failed`.
- An endpoint that keeps failing for 3 days is disabled (`disabled_reason: "deliveries failed for 3 days"`). **Send test** re-enables it.
- The endpoint drawer lists recent deliveries with status, HTTP status, attempts, last error and the payload.

## GitHub App

The GitHub App lets Comity follow pushes and pull requests, including branches no agent hook reported, and publishes a `comity/coordination` check run on pull requests. Each org creates its own private app, owned by your GitHub account or organization.

### Set up

1. **Integrations → GitHub App → Set up**. Optionally enter a GitHub organization to own the app; empty creates it under your personal account.
2. **Create GitHub App** sends you to GitHub with a prefilled app manifest. Confirm it.
3. Back in Comity, **Install on GitHub** and pick the repositories.

The app (named `Comity <org slug>`) requests the minimum it needs:

| Permission | Access |
|---|---|
| Contents | read |
| Pull requests | write |
| Checks | write |
| Metadata | read |

Events: `push`, `pull_request`, `create`, `delete`. Installation is confirmed against the installing user's own GitHub access, and an installation can belong to only one Comity org.

### What it does

- **Pushes** to non-default branches and **pull requests** (opened, reopened, synchronized, ready for review, base changed, closed) are compared with the base. With `data_mode` `full`, Comity reads the changed files to analyze symbols and contracts in memory; with `metadata_only` it records only paths and line counts. See [Security & data](/docs/admin/security#data-modes).
- Each branch becomes a participant in conflict detection: a pushed branch that no agent session is working on still shows up in teammates' conflicts.
- **Check run** `comity/coordination` on pull requests:

| Situation | Conclusion | Title |
|---|---|---|
| A live conflict with severity `block` | `failure` | "N open conflicts, M blocking" |
| Other live conflicts | `neutral` | "N open conflicts" |
| No live conflicts | `neutral` | "No open coordination conflicts" |

The check never reports `success`. It is updated when the PR's conflicts change and when you re-run it on GitHub. To gate merges on it, add `comity/coordination` as a required status check in your branch protection.

Branches idle for 14 days are closed. Repositories beyond your plan's repo limit are skipped.
