# Webhooks and REST API

## Webhooks

**Settings → Webhooks** (org admins) creates a webhook for every project of the organisation. A
webhook for one project only is created through the API, with `POST /api/v0/webhooks` and
`projectId`. You choose the events:

| Event | Sent when |
|---|---|
| `analysis.completed` | an analysis succeeded |
| `gate.status_changed` | a branch's gate status differs from its previous analysis. This includes the first analysis, and a change caused by an issue status change |

The payload is the analysis as `GET /api/v0/analyses/{id}` returns it: status, revision, gate status
and result, engines and warnings. It also carries `project` (`id`, `key`, `name`) and `branch` (`id`,
`kind`, `name`, `isMain`), and, for `gate.status_changed`, `previousGateStatus`.

Every request is a `POST` with a JSON body and these headers:

| Header | Value |
|---|---|
| `X-Qualor-Event` | the event name |
| `X-Qualor-Delivery` | the delivery id. Deliveries are at-least-once, so deduplicate on it |
| `X-Qualor-Timestamp` | Unix seconds of this attempt |
| `X-Qualor-Signature` | `sha256=` + hex HMAC-SHA256 of `"<timestamp>.<raw body>"`, keyed with the webhook's secret |

Verify the signature over the **raw** body, and reject old timestamps:

```js
import { createHmac, timingSafeEqual } from 'node:crypto';

function verify(rawBody, headers, secret, maxAgeSeconds = 300) {
  const ts = headers['x-qualor-timestamp'];
  if (Math.abs(Date.now() / 1000 - Number(ts)) > maxAgeSeconds) return false;
  const expected = 'sha256=' + createHmac('sha256', secret).update(`${ts}.${rawBody}`).digest('hex');
  const given = String(headers['x-qualor-signature'] ?? '');
  return given.length === expected.length && timingSafeEqual(Buffer.from(given), Buffer.from(expected));
}
```

- Qualor generates the secret (`whsec_…`) and shows it **once**, when the webhook is created. Replace
  it with `POST /api/v0/webhooks/{id}/regenerate-secret`.
- Each attempt has 10 s to complete. A delivery gets 7 attempts with exponential backoff (1, 2, 4, 8,
  16, 32 minutes). Only a 2xx answer counts as success, and redirects are not followed.
- **Recent deliveries** in the UI shows each delivery's status, response code and the first 1 KiB of
  the answer. Deliveries are kept 30 days. Resend one with
  `POST /api/v0/webhooks/{id}/deliveries/{deliveryId}/redeliver`.
- Webhook URLs must be `https` and resolve to public addresses. To allow `http` or internal
  receivers (a chat bot on the intranet, say), an operator sets the instance setting in PostgreSQL:

  ```sql
  INSERT INTO instance_settings (key, value) VALUES ('webhooks', '{"allowInternalHosts": true, "allowHttp": false}')
  ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now();
  ```

Typical uses: post gate failures on `main` to Slack or Teams, open a ticket when the security rating
drops, or feed a dashboard.

## REST API

The API lives under `/api/v0`. It is JSON over HTTPS, described by OpenAPI 3.1 in
[`server/openapi.json`](https://github.com/qualor-dev/qualor/blob/main/server/openapi.json). Authenticate with
`Authorization: Bearer <token>`. Version 0 may still change between releases.

```sh
export QUALOR_URL=https://qualor.example.com QUALOR_TOKEN=qlr_pat_…
q() { curl -fsS -H "Authorization: Bearer $QUALOR_TOKEN" -H 'Content-Type: application/json' "$@"; }

q "$QUALOR_URL/api/v0/projects?q=payments"                        # find projects
q "$QUALOR_URL/api/v0/projects/by-key?key=acme/payments-api"      # one project, with its main branch
q "$QUALOR_URL/api/v0/projects/<id>/branches"                     # branches and MRs with gate status
q "$QUALOR_URL/api/v0/branches/<id>/measures?metrics=coverage,ncloc"
q "$QUALOR_URL/api/v0/issues?branchId=<id>&severity=blocker&severity=high&inNewCode=true"
```

| Area | Endpoints |
|---|---|
| System | `GET /healthz`, `GET /readyz`, `GET /api/v0/system/info` |
| Projects | `GET/POST /projects`, `GET/PATCH/DELETE /projects/{id}`, `GET /projects/by-key`, `POST/GET/DELETE /projects/{id}/tokens` |
| Branches and analyses | `GET /projects/{id}/branches`, `GET /branches/{id}/analyses`, `GET /analyses/{id}`, `DELETE /branches/{id}` |
| Measures | `GET /branches/{id}/measures`, `GET /branches/{id}/measures/history`, `GET /branches/{id}/files`, `GET /branches/{id}/file?path=` |
| Issues | `GET /issues`, `GET /issues/{id}`, `POST /issues/{id}/transition`, `POST /issues/bulk-transition`, `PATCH /issues/{id}` (severity), `GET /issues/{id}/changelog` |
| Rules, profiles, gates | `GET /rules`, `/quality-profiles…`, `/quality-gates…` (conditions, copy, set-default), `GET /metrics` |
| Users and organisations | `GET/POST/PATCH /users`, `GET /organizations`, `…/members` |
| SCM | `GET/POST/PATCH/DELETE /scm-connections`, `POST /scm-connections/{id}/test` |
| Webhooks | `/webhooks…`, `/webhooks/{id}/deliveries`, `…/redeliver`, `…/regenerate-secret` |

Errors are `application/problem+json` with a stable `code`, such as `PROJECT_NOT_FOUND`,
`INSUFFICIENT_SCOPE` or `INVALID_TRANSITION`. The API answers 404 for resources you cannot see, and 403
for resources you can see but not change. The 403 carries a code that says why.

Example: mark issues as false positives in bulk:

```sh
q -X POST "$QUALOR_URL/api/v0/issues/bulk-transition" \
  -d '{"ids":["<issue id>","<issue id>"],"to":"false_positive","comment":"Generated code, see ADR-12"}'
```

Full specification: [`docs/spec/api.md`](https://github.com/qualor-dev/qualor/blob/main/docs/spec/api.md).