Skip to main content

API overview

The portal exposes a REST API rooted at /v1. The full OpenAPI 3.1 schema is generated by FastAPI and served live at https://<your-portal>/api/docs (Swagger UI), /api/redoc (Redoc), and /api/openapi.json. This page is a high-level orientation.

Audience

Engineers integrating with the portal — CI runners, partner tooling, custom dashboards. Familiarity with HTTP, JSON, and OAuth-style bearer tokens.

Full reference

This page is the orientation. For the complete, browsable endpoint-by-endpoint reference — request bodies, response schemas, and validation rules — see the API reference (Redoc). It is rendered from a committed OpenAPI snapshot and ships with the docs site (no running backend required).

Path mapping

Browser-visible paths begin with /api/.... Traefik's stripprefix middleware strips /api before forwarding to FastAPI, so the backend's internal mount points are /v1/*, /auth/*, /ws/*, /health, and FastAPI's own /docs, /redoc, /openapi.json. Operators debugging inside the backend container should drop the /api prefix.

Base URL

https://<your-portal>/v1

Trailing slashes are normalized — both /projects and /projects/ work.

Authentication

Two auth schemes are accepted on every protected endpoint. Both use the Bearer scheme — there is no separate ApiKey scheme.

Bearer JWT (interactive sessions)

Authorization: Bearer <access_token>

Issued by POST /v1/auth/login. 30-minute lifetime by default. Refresh via the rotation cookie returned at login.

API key (machine clients)

Authorization: Bearer tos_<prefix>_<secret>

The portal recognizes the tos_ prefix and routes the bearer to the API-key validator. See API keys.

Anonymous endpoints

The following do not require a JWT:

  • GET /health (backend liveness)
  • GET /healthz (frontend container liveness; not a v1 surface)
  • POST /v1/auth/register
  • POST /v1/auth/login
  • POST /v1/auth/refresh
  • POST /v1/auth/forgot-password
  • POST /v1/auth/reset-password
  • GET /v1/auth/oauth/{provider}/authorize
  • GET /v1/auth/oauth/{provider}/callback
  • POST /v1/webhooks/github (HMAC-authenticated)
  • POST /v1/webhooks/gitlab (token-authenticated)

Errors — RFC 7807

All 4xx and 5xx responses carry Content-Type: application/problem+json with this shape:

{
"type": "https://trustedoss.io/problems/forbidden",
"title": "Forbidden",
"status": 403,
"detail": "API key 'tos_a1b2c3d4_…' lacks required action 'scan:trigger'.",
"instance": "/v1/projects/01H…/scans"
}

title and detail are always English, regardless of the caller's Accept-Language or the signed-in user's UI language preference. The backend has no i18n framework (no gettext/babel, no Accept-Language handling anywhere in the request path); only apps/frontend's UI strings are translated. See Server-generated output is English-only.

Domain extensions are snake_case and modelled in the OpenAPI schema. Two well-known examples:

Type URIStatusTriggered by
…/last-super-admin409Demoting the last super-admin.
…/disk-pressure503New scan rejected because disk is above hard limit.

Pagination

There are three shapes, not one. This page previously described a single limit / offset contract; that was true of eight endpoints and wrong about the other twenty-one. Read the shape off the endpoint you are calling, in the OpenAPI schema or the table below, rather than assuming one.

ShapeQuery paramsResponse fieldsEndpoints
Offsetlimit, offsetitems, total, limit, offset8
Numbered (page_size)page, page_sizeitems, total, page, page_size15
Numbered (size)page, sizeitems, total, page, page_size6

Defaults and maxima vary within each shape as well, so do not hard-code them:

ShapePage size defaultMaximum
Offset50200 on the inventory endpoints, 500 on the per-project ones
Numbered (page_size)50, except 20 on /v1/notifications200
Numbered (size)20, except 25 on search and 100 on the source tree100, except 500 on the source tree

page is 1-based. offset is a 0-based row count. Both are capped, and a request past the cap is rejected rather than silently clamped.

sort is separate from pagination and is accepted by ten endpoints (the component, licence, obligation, vulnerability, compliance and inventory lists, plus their CSV exports). It takes a comma-separated field or -field, where the leading minus is descending, and the permitted fields are per-endpoint.

Response envelope, offset shape:

{
"items": [],
"total": 1273,
"limit": 50,
"offset": 0
}

Numbered shape, including the endpoints whose query params are page / size. The response field is page_size in both cases:

{
"items": [],
"total": 1273,
"page": 1,
"page_size": 50
}

Why three, and what happens next

They accumulated. Nothing forced a new endpoint to match an existing one, and each was locally reasonable when written. That is a defect, not a design: a client library or an export script needs a branch per shape, which is the cost this section exists to make visible rather than to excuse.

New endpoints use one shared schema, so the count above stops growing. The existing twenty-nine are left alone deliberately. TRUSCA is pre-1.0 and SECURITY.md says a minor release may change the HTTP API; converging them is a breaking change that belongs at 1.0.0, where callers expect one, rather than spread across minors as a series of smaller surprises. Adding a compatibility layer that accepts both spellings was considered and rejected: it would promise more stability than the project currently offers, and leave two code paths to keep correct in the meantime. The convergence is tracked on the roadmap.

Batch endpoints

Onboarding an organization means creating a project per repository. Two endpoints take a list so that does not become three hundred HTTP calls:

EndpointBodyCreates
POST /v1/projects:batch{"projects": [ … ]}, each entry the body POST /v1/projects takesProjects
POST /v1/scans:batch{"project_ids": [ … ], "ref": "main"}One scan per project

At most 200 rows per request. Rows are processed in order and independently: a row that fails does not undo the rows before it, and the rows after it still run. That is deliberate, and it is what makes a re-run safe.

Reading the result

{
"all_succeeded": false,
"total": 300,
"created": 287,
"already_existed": 8,
"failed": 5,
"failed_by_status": { "forbidden": 4, "rate_limited": 1 },
"rows": [ { "index": 0, "status": "created", "project_id": "…" } ]
}

Check the status code or all_succeeded, not the rows. The endpoint answers 201 when every row succeeded and 207 Multi-Status when any did not, so a script can branch on the status line alone. 200 is deliberately not used for a partial failure: client libraries treat it as success, and a batch that half-failed would be recorded as having worked.

Each row carries one of five statuses:

StatusCounts asMeaningWhat to do
createdsuccessThe project or scan was created.Nothing.
already_existssuccessIt was already there. For scans, one is already queued or running.Nothing. This is the normal result of a re-run.
forbiddenfailureThe caller is not a member of the target team.Get access, then send those rows again.
invalidfailureThe row was rejected for another reason; detail says which.Fix the row.
rate_limitedfailureThe team's concurrent-scan cap was reached. retry_after_seconds estimates the wait.Send the remaining rows again later.

already_exists counts as success on purpose. Re-running a batch is how an interrupted onboarding is finished, and on the second run most rows already exist; counting those as failures would make every re-run report failure. A re-run that changes nothing returns created: 0 with already_existed equal to total, which is how a caller knows the earlier run completed. Existing rows carry their project_id, so a caller that lost the first response can recover the ids.

Scans and the concurrency cap

POST /v1/scans:batch does not bypass the per-team concurrent-scan cap, and is not meant to: the cap protects the shared worker pool. The cap is re-counted against the team's live active-scan total for every row, so a batch starts scans up to the cap and reports the remainder as rate_limited. Queueing past the cap would move the load rather than shed it, so the remainder is refused rather than held; send it again once the earlier scans finish.

Surface map

The backend's internal paths (after Traefik strips /api):

POST /auth/register anonymous
POST /auth/login anonymous, bearer issue
POST /auth/refresh anonymous, rotation
POST /auth/logout
GET /auth/me self
POST /auth/forgot-password anonymous
POST /auth/reset-password anonymous
GET /auth/oauth/{provider}/authorize anonymous
GET /auth/oauth/{provider}/callback anonymous

GET /auth/me current user info (auth router)
GET /v1/users/me/notification-prefs
PUT /v1/users/me/notification-prefs
GET /v1/users/me/oauth-identities
DELETE /v1/users/me/oauth-identities/{identity_id} # gates last-OAuth + has-password
# 409 → urn:trustedoss:problem:last-oauth-link

GET /v1/projects list (team-scoped)
GET /v1/projects/export.csv same rows as the list, unpaginated (D9)
POST /v1/projects
GET /v1/projects/{id}
PATCH /v1/projects/{id}
DELETE /v1/projects/{id}
GET /v1/projects/{id}/sbom?format=…
GET /v1/projects/{id}/vex?format=… openvex | cyclonedx; VEX from finding triage
POST /v1/projects/{id}/vex/import consume a VEX doc (team_admin); multipart upload
GET /v1/projects/{id}/notice
GET /v1/projects/{id}/components
GET /v1/projects/{id}/scans
POST /v1/projects/{id}/scans 202 Accepted; queues a Celery task
GET /v1/projects/{id}/vulnerabilities
GET /v1/projects/{id}/licenses
GET /v1/projects/{id}/licenses/export.csv same rows as the list, unpaginated (D9)
GET /v1/projects/{id}/obligations
GET /v1/projects/{id}/obligations/{obligation_id}
PUT /v1/projects/{id}/obligations/{obligation_id}/fulfilment # If-Match optional
DELETE /v1/projects/{id}/obligations/{obligation_id}/fulfilment
GET /v1/projects/{id}/obligation-fulfilments
GET /v1/projects/{id}/gate-result

GET /v1/scans list
GET /v1/scans/{id}
POST /v1/scans/{id}/post-pr-comment

GET /v1/components/{component_id}

GET /v1/license_findings/{finding_id}

GET /v1/vulnerability_findings/{finding_id}
PATCH /v1/vulnerability_findings/{finding_id}/status # VEX state, If-Match required

GET /v1/approvals
GET /v1/approvals/{id}
POST /v1/approvals
PATCH /v1/approvals/{id}/transition # If-Match required
DELETE /v1/approvals/{id}

GET /metrics off by default; 404 when off or on a wrong token
GET /v1/notification-rules/org/{organization_id} who else hears, deployment-wide
POST /v1/notification-rules/org/{organization_id} super_admin only
GET /v1/notification-rules/teams/{team_id} includes the organization's own
POST /v1/notification-rules/teams/{team_id} team_admin
DELETE /v1/notification-rules/{rule_id}
GET /v1/notifications
GET /v1/notifications/unread-count
PATCH /v1/notifications/read-all
PATCH /v1/notifications/{id}/read

GET /v1/api-keys
POST /v1/api-keys
DELETE /v1/api-keys/{id} revoke

POST /v1/webhooks/github anonymous, HMAC
POST /v1/webhooks/gitlab anonymous, token

# /v1/admin/** — super_admin only (404-existence-hide for non-admins)
GET /v1/admin/users
GET /v1/admin/users/{id}
PATCH /v1/admin/users/{id}/role
PATCH /v1/admin/users/{id}/deactivate
PATCH /v1/admin/users/{id}/activate
POST /v1/admin/users/{id}/password-reset
GET /v1/admin/teams
POST /v1/admin/teams
GET /v1/admin/teams/{id}
PATCH /v1/admin/teams/{id}
DELETE /v1/admin/teams/{id}
POST /v1/admin/teams/{id}/members
DELETE /v1/admin/teams/{id}/members/{user_id}
GET /v1/admin/scans global queue
POST /v1/admin/scans/{scan_id}/cancel cancel a running scan
GET /v1/admin/audit query the audit log
GET /v1/admin/audit/export.csv streaming CSV
GET /v1/admin/health component liveness
GET /v1/admin/disk
GET /v1/admin/backup list backups
POST /v1/admin/backup trigger a manual backup
GET /v1/admin/backup/{name}/download
POST /v1/admin/backup/restore upload + restore (typing-gated)
DELETE /v1/admin/backup/{name}

The full schema (request bodies, response shapes, validation rules) lives at /api/docs on every running install.

Optimistic concurrency

Endpoints that mutate domain rows with stateful workflows accept (and require) the If-Match request header carrying the row's current version integer. PATCH /v1/approvals/{id}/transition and PATCH /v1/vulnerability_findings/{finding_id}/status both use this pattern. Mismatches return 412 Precondition Failed with a Problem Details body that includes the current version.

WebSockets

The portal exposes one WebSocket endpoint:

WSS /api/ws/scans/{scan_id}

(After Traefik strips /api, the backend handles this at /ws/scans/{scan_id}.)

Authentication is handled by the first message the client sends, not by query string or headers:

{ "type": "auth", "token": "<JWT access token>" }

The gateway closes the connection with code 1008 / reason auth_timeout if the first frame does not arrive within WEBSOCKET_AUTH_TIMEOUT_SECONDS (default 1.0 s).

Reconnect with exponential backoff. Each reconnect receives one initial-sync frame from the current scan row before live events flow.

Server frames

Two kinds of frame travel down this one socket, told apart by type.

Progress says where the pipeline is:

{ "type": "progress", "percent": 70, "step": "scancode", "ts": "2026-05-10T12:34:56Z" }

A frame with no type at all is a progress frame. The discriminator was added after the envelope shipped, so clients written against {percent, step, ts} keep working.

Log carries one line of a scan tool's output:

{ "type": "log", "stage": "scancode", "stream": "stderr", "line": "ERROR: no license detected in LICENSE.txt", "ts": "2026-05-10T12:34:56Z" }

stage names the pipeline step that produced the line, drawn from the same vocabulary as a progress frame's step (cdxgen, scancode, scanoss, trivy, …). stream is stdout or stderr, and nothing else: the publisher normalises any other value to stdout. It is what lets a client tint or filter a tool's error output without parsing the text.

Two limits shape what arrives. A line longer than SCAN_LOG_LINE_MAX_LEN (default 2000) is truncated, and once a scan has published SCAN_LOG_MAX_LINES_PER_SCAN lines (default 20000, shared across all its stages) no further log frames are sent. A client should not read the end of the log stream as the end of the scan; progress frames keep coming either way.

Treat an unrecognised type as a frame to skip rather than a protocol error. The portal's own client drops any frame it cannot read as one of these two shapes and leaves the socket open, which is what lets a new frame type ship without breaking older clients.

Close codes

Every close the server sends, and what it means. The source is apps/backend/api/v1/ws.py, the only place the endpoint closes.

CodeReasonCause
1001newer_connectionPer-user connection cap (WEBSOCKET_MAX_CONNECTIONS_PER_USER, default 8) exceeded; the oldest socket is evicted. The count is kept in a Redis-backed registry shared by every backend process, so it is exact regardless of worker or pod count, the pre-W4 per-process count, and the "which worker a socket lands on decides" behavior it produced, are gone. One open scan page holds two connections, so a second tab can still evict a first tab's socket once the two tabs' four connections push the same user over the cap; it just no longer depends on luck.
1008auth_timeoutNo first frame within WEBSOCKET_AUTH_TIMEOUT_SECONDS.
1008auth_invalidThe token did not decode, was not an access token, or its subject is not a user id.
1008auth_inactiveThe account is deactivated or gone.
1008origin_rejectedThe Origin header is not in CORS_ALLOWED_ORIGINS. See the caveat below: a client never observes this code.
1011internalAn error inside the event forward loop. The ASGI server also closes with 1011 and reason keepalive ping timeout when a client stops answering pings, so this code has two producers.
4400bad_messageThe first frame was not a valid auth message.
4403forbiddenThe caller is not in the team that owns the scan.
4404scan_not_foundThe id in the URL is not a UUID, or no such scan exists. Both close the same way.
4429capacity_at_limitGlobal connection cap (WEBSOCKET_MAX_CONNECTIONS_GLOBAL, default 500) reached. The new connection is refused outright; no existing connection (this user's or anyone else's) is evicted to make room for it. Deliberately not 1008, the client treats 1008 as an expired session and signs the reader out, which would be wrong for a capacity refusal.

Two things a client cannot learn from that table alone:

  • origin_rejected never reaches the client as 1008. It is sent before the handshake is accepted, which the ASGI server turns into an HTTP 403 on the upgrade. The browser reports a close code of 1006 with an empty reason, indistinguishable from a network failure.
  • 1006 is not in the table because the server never sends it. Browsers synthesise it whenever no close frame arrived: a dropped connection, a sleeping machine, a proxy idle timeout, or the origin rejection above. Client copy for 1006 should describe a connection that failed, not a decision the server made.

The endpoint sends no 1000. A client that observes one closed the socket itself.

OpenAPI download

curl -sS https://trustedoss.example.com/api/openapi.json > openapi.json

The schema is regenerated at startup. Pin against a release tag if you generate clients (openapi-generator-cli, openapi-typescript).

Rate limits

  • Login (/auth/login): IP-keyed 5/minute. 429 with Retry-After: 60.
  • Forgot password (/auth/forgot-password): IP-keyed 5/minute (configurable via PASSWORD_RESET_RATE_LIMIT); per-address cooldown returned as Retry-After.
note

Idempotency-Key request handling and X-RateLimit-* response headers are on the roadmap and are not implemented in this release.

Cancelling scans

Regular users do not cancel scans directly. Operators cancel via POST /v1/admin/scans/{scan_id}/cancel (super-admin only).

Observability

Set X-Request-ID on outbound calls; the portal echoes it in the response and logs it on every line for that request. Without the header, the portal generates a UUIDv7 and returns it.

Versioning

The path includes /v1. Breaking changes go to /v2. Within /v1:

  • New optional fields on responses are not breaking.
  • New required fields on requests are gated behind a new endpoint or behind a feature header.

See also