Webhooks and REST API
Webhooks tell your own systems when an analysis ends, a quality gate changes or new vulnerabilities are found, for example to open a ticket or feed a dashboard. The REST API lets scripts read and change what Qualor holds.
Webhooks
Org admins create a webhook in Settings → Webhooks with New webhook. Its Applies to
field is All projects or one project. A webhook for one project is also listed under Project →
Settings → Webhooks, where New webhook creates one for that project. Through the API, send
projectId to POST /api/v0/webhooks. An organisation has at most 50 webhooks, counting project,
Slack and Teams webhooks.
The type Generic (signed JSON) posts JSON to a receiver of your own, as described below. The
types Slack and Microsoft Teams send formatted messages about the main branch’s quality
gate, exploited vulnerabilities and a weekly security digest
(Slack and Microsoft Teams). They have no secret and send no
X-Qualor-* headers.
A generic webhook sends the events you choose:
| 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 |
vulnerabilities.found |
once a day at 06:00 UTC, for each project with new High or Critical vulnerabilities in a tracked branch or version. Business and Enterprise (vuln.alerts) |
The payload of analysis.completed and gate.status_changed 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). For gate.status_changed it adds previousGateStatus. A tag scan sends no webhook, so
the payload’s version is always null (Releases and versions).
The payload of vulnerabilities.found lists what the project’s
digest lists, worst first:
{
"project": {
"id": "0190f6b0-7a4e-7c3a-9b1d-2f6e8a1c4d55",
"key": "acme/payments",
"name": "Payments",
"url": "https://qualor.example.com/projects/0190f6b0-7a4e-7c3a-9b1d-2f6e8a1c4d55/vulnerabilities"
},
"database": { "builtAt": "2026-11-09T18:12:04.000Z" },
"vulnerabilities": [
{
"vulnerabilityId": "CVE-2026-1001",
"severity": "critical",
"title": "Remote code execution in axios",
"packageName": "axios",
"installedVersions": ["1.7.2"],
"fixedVersion": "1.7.4",
"exploited": { "dateAdded": "2026-10-30" },
"targets": ["main", "2.3.4"]
}
],
"more": 0
}
| Field | Meaning |
|---|---|
project.url |
the project’s Vulnerabilities tab, or null without QUALOR_PUBLIC_URL |
database.builtAt |
when the vulnerability database in use was built, or null without one |
vulnerabilities[].severity |
critical or high |
vulnerabilities[].exploited |
the CISA KEV entry, or null when it is not in KEV or the licence has no vuln.priority |
vulnerabilities[].targets |
the tracked branches and versions where it is new, main first |
more |
how many were left out: a payload lists at most 1 000 vulnerabilities |
A project with nothing new sends no delivery.
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:
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));
}
The secret. Qualor generates it (whsec_…) and shows it once, when the webhook is created.
Through the API you can send your own secret instead, 16 to 256 characters, to
POST /api/v0/webhooks or PATCH /api/v0/webhooks/{id}. Qualor then returns none. To have a new
one generated, press Rotate secret on the webhook (API:
POST /api/v0/webhooks/{id}/regenerate-secret). The old secret stops signing at once, so update the
receiver with the new one, which is also shown once.
Delivery. Each attempt has 10 s to complete. A delivery gets 7 attempts, with backoff of 1, 2, 4, 8, 16 and 32 minutes between them. Only a 2xx answer counts as success, and redirects are not followed.
Failures. When a delivery gives up after its 7 attempts, instance administrators get an email (Email), at most one a day for each webhook. Organisation administrators get the same email for their organisation’s webhooks, unless they turned it off in Settings → Notifications (Email). A webhook that is switched off alerts nobody.
History. Each webhook shows its last 20 deliveries as a strip, with the share delivered:
delivered above the line, failed below it, pending on it. Recent deliveries lists them with each
one’s status, response code and the first 1 KiB of the answer. Deliveries are kept 30 days.
Refresh reloads the list. Send again sends a delivery once more (API:
POST /api/v0/webhooks/{id}/deliveries/{deliveryId}/redeliver).
Receiver addresses. Webhook URLs must be https and resolve to public addresses. To allow
http or internal receivers, such as a chat bot on the intranet, an operator sets the instance
setting in PostgreSQL:
INSERT INTO instance_settings (key, value) VALUES ('webhooks', '{"allowInternalHosts": true, "allowHttp": false}')
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now();
Audit event streaming (enterprise)
With a licence that lists audit-log and audit-log.stream (the Enterprise plan), Settings →
Audit settings configures a stream that posts new audit events to a SIEM as they are recorded.
There is one stream per instance, with its own URL and its own secret. A delivery carries
X-Qualor-Event: audit.events and the same X-Qualor-Timestamp and X-Qualor-Signature headers as
a webhook, verified the same way. A batch can be sent again after a timeout, so deduplicate on each
event’s seq. The body, the retries, what is never sent and the Business plan’s behaviour are in
Streaming to a SIEM.
REST API
The API lives under /api/v0. It is JSON over HTTPS, described by OpenAPI 3.1 in
server/openapi.json. Authenticate with
Authorization: Bearer <token>. Version 0 can change between releases.
The MCP server and its OAuth sign-in endpoints (/mcp, /oauth/* and /.well-known/*)
are outside /api/v0. Their tokens belong to the app a person allowed and work only on /mcp. They
are not API tokens, and an API token does not work on /mcp.
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 (version, edition, features), GET /api/v0/system/version (the version alone, for any token, project analysis tokens too); instance admins: GET/PUT/DELETE /api/v0/license (Enterprise) |
| Projects | GET/POST /projects, GET/PATCH/DELETE /projects/{id}, GET /projects/by-key, POST/GET/DELETE /projects/{id}/tokens, GET /projects/{id}/members and PUT/DELETE /projects/{id}/members/{userId} (a role on one project, Roles and the audit log) |
| 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= |
| Dependencies | GET /projects/{id}/dependencies (every edition): the dependencies of a branch or a version, and their licences. Takes ?branch=, ?mr= or ?version=, filters and paging (Dependency licences) |
| Versions | GET /projects/{id}/versions, PATCH /projects/{id}/versions/{versionId} (a version’s tracking), GET/PUT /projects/{id}/vulnerability-monitoring (which branches and versions are tracked) (Releases and versions) |
| Vulnerabilities | GET /projects/{id}/vulnerabilities/targets, GET /projects/{id}/vulnerabilities (read access), PUT /projects/{id}/vulnerability-decisions (issue.triage; 422 REASON_REQUIRED or COMMENT_REQUIRED). POST /issues/{id}/transition and POST /issues/bulk-transition accept reason (Dependency vulnerabilities) |
| SBOM and VEX (enterprise) | GET /projects/sbom, GET /projects/vex, addressed by ?projectKey=; a project analysis token reads its own project (below) |
| 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 (each user with hasPassword and sso; GET /users?signIn=no-password), GET /users/lookup?username=, GET /users/search?q= (below), GET /organizations, …/members (each with managedBy; ?q=, trimmed, keeps the active members that match, as /users/search does) |
| Sign-in | POST /auth/login, POST /auth/logout, GET /auth/me, GET /auth/methods (public: the password policy, the single sign-on buttons and whether there is a demo), POST /auth/demo (public: signs in to the read-only demo) |
| SCM | GET/POST/PATCH/DELETE /scm-connections, POST /scm-connections/{id}/test. A connection takes an optional write-only webhookSecret (16 to 256 printable ASCII characters; null removes it). A gitlab or github connection answers with webhookSecretSet, webhookSecretReadable and webhookUrl, the address of its webhook (GitLab, GitHub) |
| Webhooks | /webhooks…, /webhooks/{id}/deliveries, …/redeliver, …/regenerate-secret, POST /webhooks/{id}/test (Slack and Teams webhooks). A webhook has kind (generic, slack or teams, set when it is created), timezone (Slack and Teams) and urlMasked (true when url shows only the end of the real URL) |
| AI assistant | POST /issues/{id}/ai/{explain,triage,fix}, GET /issues/{id}/ai, GET /ai-requests/{id}, POST /ai-requests/{id}/post, GET /organizations/{id}/ai; instance admins: GET/PUT /system/llm, POST /system/llm/test (AI assistant) |
| AI review | GET/PUT /organizations/{id}/ai/review (organisation admins). The scanner uses GET /projects/review-context and POST /analyses/{id}/review-snapshot. A finding is an issue of the engine ai (GET /issues?engine=ai), and GET /issues/{id} gives it aiReview. Replies and commands have no API of their own (AI review) |
| Email (instance admins) | GET/PUT /system/mail, POST /system/mail/test, GET /system/mail/messages (Email) |
| Automatic fix suggestions (enterprise) | GET/PUT /organizations/{id}/ai/auto-fix (organisation admins), PUT /projects/{id}/ai/auto-fix (project settings). The project object carries autoFix. Without llm.auto-fix: the organisation GET answers available: false, its PUT answers 403 FEATURE_NOT_LICENSED only when it would switch suggestions on, and the project PUT always answers 403 FEATURE_NOT_LICENSED (Automatic fix suggestions) |
| Licence policy (business and enterprise) | GET /organizations/{id}/license-policy (anyone in the organisation: the policy, the licence groups and the SPDX ids), PUT /organizations/{id}/license-policy (organisation admins, org.licenses.manage; "policy": null removes it). Without license-policy, the GET answers available: false and the PUT 403 FEATURE_NOT_LICENSED. An unknown licence id gives 400 INVALID_LICENSE_ID (Dependency licences) |
| Single sign-on (enterprise) | /ee/sso/connections…, /ee/sso/settings, /ee/sso/me/identities, /ee/sso/users/{userId}/identities (below) |
| SCIM (enterprise) | GET/POST /ee/scim/tokens, DELETE /ee/scim/tokens/{id}, and the SCIM 2.0 service under /ee/scim/v2 (below) |
| Audit log (enterprise) | GET /ee/audit/events, GET /ee/audit/export, GET /ee/audit/head, GET /ee/audit/verify, GET/PUT /ee/audit/settings, POST /ee/audit/settings/stream/{regenerate-secret,test} (Roles and the audit log). The two stream routes, and a PUT with a stream object, also need audit-log.stream (403 FEATURE_NOT_LICENSED without it). Retention alone and "stream": null need only audit-log |
| Compliance reports (enterprise) | GET/POST /ee/compliance/reports, GET/DELETE /ee/compliance/reports/{id}, GET /ee/compliance/reports/{id}/download (?format=json, csv or pdf) (Compliance reports) |
| Support bundle (business and enterprise) | GET /ee/support/bundle, for an instance admin. One bundle at a time: 429 RATE_LIMITED otherwise (The support bundle) |
| Portfolio reports (enterprise) | GET /ee/portfolio, GET /ee/portfolio/projects, GET /ee/portfolio/export (?format=csv or pdf), each with ?scope=organization&organizationId=… or ?scope=all (Portfolio reports) |
Errors are application/problem+json with a stable code, such as PROJECT_NOT_FOUND,
INSUFFICIENT_SCOPE, INVALID_TRANSITION or AI_QUOTA_EXCEEDED. The API answers 404 for resources
you cannot see, and 403, with a code that says why, for resources you can see but not change.
GET /auth/me and every project answer (GET /projects, GET /projects/{id},
GET /projects/by-key) carry the caller’s effective permissions for that organisation or project,
so a script can tell what it may do without trying. /auth/me also lists the caller’s own
projectGrants across every organisation, at most 1 000 of them.
Example: mark issues as false positives in bulk:
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"}'
SBOM and VEX export (enterprise)
GET /projects/sbom and GET /projects/vex download a file for a branch or a version. They need
the sbom feature (403 FEATURE_NOT_LICENSED without it).
| Parameter | Meaning |
|---|---|
projectKey |
Required. The project’s key, as the scanner uses it |
branch |
A branch name. A merge request is not a target |
version |
A version label. Give branch or version, not both (422); neither means the main branch |
format |
SBOM: cyclonedx (default) or spdx. VEX: cyclonedx (default) or openvex |
| Answer | Meaning |
|---|---|
| 200 | The file, as an attachment named <key>-<target><suffix> (.cdx.json, .spdx.json, .vex.cdx.json or .openvex.json). Never cached |
404 PROJECT_NOT_FOUND |
No such project, or the caller cannot see it. The project token of another project gets it too |
404 NOT_FOUND |
No such branch or version. For the SBOM, the target has no dependency inventory yet. For the VEX, vulnerability monitoring is off, or the target has not been checked yet |
| 403 | FEATURE_NOT_LICENSED, or INSUFFICIENT_SCOPE for a personal token without the read scope |
A project analysis token reads the SBOM and VEX of its own project and no other project’s, so a release job needs no personal token. A person needs read access to the project.
curl -fsS -G -H "Authorization: Bearer $QUALOR_PROJECT_TOKEN" -OJ \
--data-urlencode "projectKey=acme/payments-api" --data-urlencode "version=$RELEASE_TAG" \
--data-urlencode "format=spdx" "$QUALOR_URL/api/v0/projects/sbom"
curl -fsS -G -H "Authorization: Bearer $QUALOR_PROJECT_TOKEN" -OJ \
--data-urlencode "projectKey=acme/payments-api" --data-urlencode "version=$RELEASE_TAG" \
--data-urlencode "format=openvex" "$QUALOR_URL/api/v0/projects/vex"
Searching users
GET /users/search?q= suggests people for the user name fields of the app. It answers active users
whose user name starts with q, or one of whose display name’s words does, ignoring case, ordered
by user name. Each user has only id, username and displayName, never an email address.
| Parameter | Meaning |
|---|---|
q |
Spaces around it are trimmed. What is left needs at least 2 characters (1 for an instance admin; shorter answers 422), and at most 64 UTF-16 units (an emoji counts as 2). % and _ match themselves |
limit |
the most users to answer: 1 to 10, 10 by default |
excludeOrganization=<id> |
leaves out the members of that organisation, which the caller must manage |
excludeProject=<id> |
leaves out the people with a role granted on that project, whose roles the caller must manage (Organization admin of its organisation) |
It needs what GET /users/lookup needs: a token with the Admin scope, of an instance admin or
of someone who manages the members of an organisation. An excluded organisation or project answers
403 when the caller can see it but not manage it, and 404 when they cannot see it. Each caller gets
60 searches a minute (429 RATE_LIMITED beyond that). GET /users/lookup has its own 30.
curl -fsS -H "Authorization: Bearer $QUALOR_ADMIN_TOKEN" \
"$QUALOR_URL/api/v0/users/search?q=smi&excludeOrganization=<org id>"
Single sign-on and SCIM (enterprise)
Instance admins manage single sign-on through these endpoints, below /api/v0, with a session or a
personal token with the Admin scope (Single sign-on and SCIM). They need a
licence that lists sso. Several enabled connections need sso.multi, and SCIM needs scim: both
are in the Enterprise plan only. Secrets are write-only: an answer says only whether one is set
(clientSecretSet, spKeySet).
| Endpoint | What it does |
|---|---|
GET /auth/methods |
public: password (everyone or break_glass_only, as it applies now) and providers, the connections in effect with their startUrl (every enabled one with sso.multi, else the oldest enabled one), empty while sso is not licensed; and demo, whether the sign-in page offers the read-only demo |
GET/POST /ee/sso/connections, GET/PATCH/DELETE /ee/sso/connections/{id} |
the connections (at most 10). An answer’s urls holds the values to copy into the IdP, or null while QUALOR_PUBLIC_URL is unset; inEffect says whether the connection signs people in (One connection or several) |
POST /ee/sso/connections/{id}/test |
OIDC: reads discovery and the JWKS now, and lists the endpoints (the authorization endpoint the browser opens, and those Qualor will call); SAML: the certificates’ fingerprints and expiry, and whether the SSO URL is allowed |
POST /ee/sso/connections/{id}/saml/metadata |
reads the IdP’s metadata URL and answers what it holds, for review; saving is a PATCH |
GET/PUT /ee/sso/connections/{id}/mappings |
the group mappings; a PUT replaces the whole list (at most 500) |
GET/PUT /ee/sso/settings |
who may sign in with a password (passwordSignIn, breakGlassUserIds), and forced while QUALOR_FORCE_PASSWORD_SIGN_IN is set |
GET /ee/sso/users/{userId}/identities, DELETE …/{identityId} |
a user’s linked accounts; unlink one, a SCIM one included |
GET /ee/sso/me/identities, DELETE …/{identityId} |
your own linked accounts (any signed-in user) |
POST /ee/sso/connections/{id}/link |
a browser session only: answers { url }, the IdP page to open to link another account |
GET/POST /ee/scim/tokens, DELETE /ee/scim/tokens/{id} |
the SCIM tokens (feature scim). Create with { "connectionId", "name", "expiresAt"? }; the answer’s token is shown once |
/ee/scim/v2/… |
the SCIM 2.0 service (ServiceProviderConfig, ResourceTypes, Schemas, Users, Groups), authenticated with a SCIM token, not a Qualor token; its errors use the SCIM error format |
The browser flows (/ee/sso/{id}/start, /ee/sso/oidc/{id}/callback, /ee/sso/saml/{id}/acs,
/ee/sso/finish) and Qualor’s public SAML metadata (/ee/sso/saml/{id}/metadata) are for the
browser and the IdP, not for scripts. A failed flow ends on the sign-in page with
?sso_error=<code> (Troubleshooting).
| Status and code | Meaning |
|---|---|
409 PUBLIC_URL_REQUIRED |
enabling a connection needs QUALOR_PUBLIC_URL |
409 SSO_CONNECTION_LIMIT_REACHED |
there are already 10 connections, in every plan |
409 SSO_MULTI_NOT_LICENSED |
without sso.multi (the Business plan): a POST with "enabled": true, or a PATCH enabling a disabled connection, while another connection is enabled. Nothing is saved. Create it disabled, or disable the enabled one first |
409 SSO_CONNECTION_NAME_TAKEN |
another connection has that name (case is ignored) |
409 SCIM_TOKEN_LIMIT_REACHED |
the connection already has 5 active SCIM tokens: revoke one first |
409 LAST_BREAK_GLASS_ADMIN |
PATCH /users/{id} would deactivate or demote the last break-glass admin while password sign-in is limited to them |
409 LAST_SIGN_IN_METHOD |
unlinking would leave the user no way to sign in: an instance admin sets a password first (PATCH /users/{id}, which ends the user’s sessions and revokes their personal tokens), and it counts only while password sign-in is allowed for that user |
409 LAST_SSO_CONNECTION |
PATCH disabling, or DELETE deleting, the last enabled connection while password sign-in is limited to break-glass admins: set it back to everyone first (or, with sso.multi, enable another connection first) |
409 SCIM_MANAGED_IDENTITY |
your own unlink of an account link the IdP provisions through SCIM: an instance admin may remove it |
503 SSO_UNAVAILABLE |
a link could not start because the identity provider cannot be used now (its discovery or configuration failed): try again later |
403 SESSION_REQUIRED |
linking needs a browser session, not a token |
403 FEATURE_NOT_LICENSED |
the licence does not list sso (or scim, for the SCIM routes), or it lapsed |
422 VALIDATION_FAILED |
a field is not valid; errors[].path names it, such as body.oidc.issuer, or body.breakGlassUserIds when “break-glass admins only” has no usable admin |
Example: list the sign-in methods (no token needed):
curl -fsS "$QUALOR_URL/api/v0/auth/methods"