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.
Engineers integrating with the portal — CI runners, partner tooling, custom dashboards. Familiarity with HTTP, JSON, and OAuth-style bearer tokens.
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).
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/registerPOST /v1/auth/loginPOST /v1/auth/refreshPOST /v1/auth/forgot-passwordPOST /v1/auth/reset-passwordGET /v1/auth/oauth/{provider}/authorizeGET /v1/auth/oauth/{provider}/callbackPOST /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 URI | Status | Triggered by |
|---|---|---|
…/last-super-admin | 409 | Demoting the last super-admin. |
…/disk-pressure | 503 | New 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.
| Shape | Query params | Response fields | Endpoints |
|---|---|---|---|
| Offset | limit, offset | items, total, limit, offset | 8 |
Numbered (page_size) | page, page_size | items, total, page, page_size | 15 |
Numbered (size) | page, size | items, total, page, page_size | 6 |
Defaults and maxima vary within each shape as well, so do not hard-code them:
| Shape | Page size default | Maximum |
|---|---|---|
| Offset | 50 | 200 on the inventory endpoints, 500 on the per-project ones |
Numbered (page_size) | 50, except 20 on /v1/notifications | 200 |
Numbered (size) | 20, except 25 on search and 100 on the source tree | 100, 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:
| Endpoint | Body | Creates |
|---|---|---|
POST /v1/projects:batch | {"projects": [ … ]}, each entry the body POST /v1/projects takes | Projects |
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:
| Status | Counts as | Meaning | What to do |
|---|---|---|---|
created | success | The project or scan was created. | Nothing. |
already_exists | success | It was already there. For scans, one is already queued or running. | Nothing. This is the normal result of a re-run. |
forbidden | failure | The caller is not a member of the target team. | Get access, then send those rows again. |
invalid | failure | The row was rejected for another reason; detail says which. | Fix the row. |
rate_limited | failure | The 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.
| Code | Reason | Cause |
|---|---|---|
| 1001 | newer_connection | Per-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. |
| 1008 | auth_timeout | No first frame within WEBSOCKET_AUTH_TIMEOUT_SECONDS. |
| 1008 | auth_invalid | The token did not decode, was not an access token, or its subject is not a user id. |
| 1008 | auth_inactive | The account is deactivated or gone. |
| 1008 | origin_rejected | The Origin header is not in CORS_ALLOWED_ORIGINS. See the caveat below: a client never observes this code. |
| 1011 | internal | An 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. |
| 4400 | bad_message | The first frame was not a valid auth message. |
| 4403 | forbidden | The caller is not in the team that owns the scan. |
| 4404 | scan_not_found | The id in the URL is not a UUID, or no such scan exists. Both close the same way. |
| 4429 | capacity_at_limit | Global 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_rejectednever 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 of1006with an empty reason, indistinguishable from a network failure.1006is 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 withRetry-After: 60. - Forgot password (
/auth/forgot-password): IP-keyed 5/minute (configurable viaPASSWORD_RESET_RATE_LIMIT); per-address cooldown returned asRetry-After.
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
- API reference (Redoc) — full endpoint-by-endpoint schema, hosted with the docs.
/api/docs(Swagger UI) on every install.- Architecture
- API keys
- Webhooks