REST API
The management API lives under /api/v1; the web UI, the CLI and the MCP server all use it. Package-manager protocols (npm, Maven, Docker, ...) are separate native routes, see formats.
Spec and explorer
| URL | Content |
|---|---|
/api/v1/openapi.json | OpenAPI 3.1 spec (embedded from internal/api/openapi.json) |
/api/v1/openapi.yaml | same spec rendered as YAML |
/api/reference | Interactive API reference (Scalar), also linked from the About dialog |
/swagger, /swagger/ | 301 redirects to /api/reference (the old Swagger UI path) |
These routes need no authentication. info.version of the spec is the running server version, info.license is AGPL-3.0-or-later. The spec is the reference for request and response schemas; a test (TestOpenAPISpecMatchesRoutes) fails when a registered route is missing from it or the spec documents a route nobody serves. GET /health, GET /ready and GET /metrics are also unauthenticated and are covered in monitoring.
API reference
The reference is served by the binary itself: the Scalar bundle (pinned version, MIT) is embedded, so it works on air-gapped hosts and makes no request to another origin. The page runs under Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none' with no inline script; telemetry, the hosted request proxy and external fonts are switched off.
"Test request" sends the call to the same origin and works in two ways:
- Session: sign in to the web UI first. The browser attaches the
oa_sessioncookie and the page adds theX-CSRF-Tokenheader (read from theoa_csrfcookie) toPOST,PUT,PATCHandDELETE, so mutations are not rejected with403 csrf_protection. - Token: open the authentication panel, choose
bearerAuthand paste an access token. Bearer calls need no CSRF header.
To turn it off set api.docs.enabled: false (OPENARTIFACT_API_DOCS_ENABLED=false, see the configuration reference). /api/reference and the /swagger redirects then answer 404 and the About dialog drops the link. /api/v1/openapi.json and /api/v1/openapi.yaml are always served, because the web client and external tooling rely on them. The bundle is embedded as internal/api/apiref/scalar-<version>.js.gz; scripts/update-scalar.sh <version> vendors a new one.
Authentication
Details, token kinds and SSO are in authentication; permissions in RBAC. In short:
- API token:
Authorization: Bearer <TOKEN>. No CSRF header needed. Tokens areaccess(management API, per the owner's permissions) ordeploy(repository credentials; never grantedmanage, so no/api/v1management calls). - Session cookie:
POST /api/v1/auth/login({"username","password"}) setsoa_session(HttpOnly, SameSite=Lax) and a readableoa_csrfcookie. State-changing requests (POST,PUT,PATCH,DELETE) on a session must echo it inX-CSRF-Token, otherwise403 {"error":"csrf_protection",...}. Login is rate limited (429 rate_limitedwithRetry-After). - Self-service limits: an API token cannot create or revoke tokens, change a password, or edit its own user profile; those need a login session. An account flagged
must_change_passwordgets403 password_change_requiredeverywhere exceptchange-passwordandlogout. - Gate: every
/api/v1/*route requires the globalmanagepermission, exceptGET /repositories,GET /packages/recentandGET /search(any authenticated user; results are filtered per repository),/projects/**(decided by the caller's project role; unlisted sub-paths still needmanage),PATCH/DELETE /repository/{name},/repositories/{name}/cleanup-policies...and/repositories/{name}/quota(manage, or maintainer/owner of the repository's project),GET /repository/{name}(readon that repository),/repositories/{name}/{packages,debs,comps}and/repositories/{name}/trash...(read/write/delete, ordelete, on that repository;DELETE .../trashneedsmanageon it). Anonymous callers can only read public repositories (or whatallowAnonymousReadexposes).
Example: token and repository
Tokens are minted from a login session, then used for everything else:
HOST=http://localhost:8080
# 1. log in (cookie jar), read the CSRF cookie
curl -sS -c jar.txt -X POST "$HOST/api/v1/auth/login" \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"<PASSWORD>"}'
CSRF=$(awk '$6=="oa_csrf"{print $7}' jar.txt)
# 2. create an access token; the secret is only returned here ("token")
curl -sS -b jar.txt -X POST "$HOST/api/v1/auth/tokens" \
-H "X-CSRF-Token: $CSRF" -H 'Content-Type: application/json' \
-d '{"name":"ci","kind":"access"}'
# -> {"id":"...","name":"ci","kind":"access","scopes":[],"created_at":"...","token":"<TOKEN>"}
# 3. use it
curl -sS -X POST "$HOST/api/v1/repositories" \
-H "Authorization: Bearer <TOKEN>" -H 'Content-Type: application/json' \
-d '{"name":"npm-hosted","format":"npm","description":"internal packages","public":false}'
curl -sS -H "Authorization: Bearer <TOKEN>" "$HOST/api/v1/repositories?limit=10"From a repository checkout, scripts/dev-token.sh does steps 1 and 2 and prints only the token: OA_PASS=<PASSWORD> scripts/dev-token.sh http://localhost:8080 (--password-stdin reads the password from stdin; --change-password with OA_NEW_PASS completes the forced password change of a fresh bootstrap admin; OA_USER, OA_TOKEN_NAME and OA_TOKEN_KIND override the defaults). It needs curl and jq.
Token body fields: name (required, max 100 characters), kind (access or deploy), optional scopes (permission target IDs; a scope can only narrow the owner's access) and expires_at (RFC 3339, in the future, at most 2 years out). Creating repositories requires an administrator. Valid format values: deb, rpm, maven, npm, docker, helm, pypi, nuget, go, cargo, rubygems, raw, conda, conan, ivy, sbt, terraform, apk. The proxy/group options (repo_type, ...) are in the spec; see hosted, proxy and group repositories.
Conventions
- JSON, snake_case. Request and response bodies use snake_case keys. Timestamps are RFC 3339 strings (
created_at,expires_at, ...); optional ones arenull. - Unknown request fields are ignored, not rejected: a misspelled key silently does nothing, so check the spec.
- Secrets are never returned (webhook secrets, upstream passwords, SSO client secrets, AI keys, storage credentials); write-only fields come back masked or as
has_*flags.
Errors
Handler errors share one shape; details is only present when a handler adds it:
{"error": "name must be 1-100 alphanumeric/hyphen/underscore characters", "code": "validation_failed"}code | Status | Meaning |
|---|---|---|
bad_request | 400 | malformed JSON or parameters |
validation_failed | 400 | well-formed request with invalid values |
unauthorized | 401 | authentication required or invalid |
invalid_credentials | 401 | wrong username/password |
forbidden | 403 | authenticated but not permitted |
admin_required | 403 | administrator permission needed |
not_found | 404 | missing resource, or a repository the caller may not see |
conflict | 409 | duplicate or state conflict |
immutable_release | 409 | a bundle promotion would overwrite a released version in a repository with immutable releases (promotion) |
artifact_conflict | 409 | a bundle promotion found different content at the same coordinates in the target |
artifact_missing | 409 | a bundle item is no longer stored in its source repository |
quota_exceeded | 507 | the target repository's quota cannot hold the promoted artifacts |
ARTIFACT_QUARANTINED | 403 | a bundle promotion was refused because a source artifact is quarantined |
payload_too_large | 413 | body over the endpoint limit |
rate_limited, too_many_scans | 429 | throttled |
not_implemented | 501 | endpoint exists but the feature is not available |
internal_error | 500 | server error; the cause is logged, never returned |
LICENSE_BLOCKED | 403 | a package upload or bundle promotion was rejected by a license policy with action block; the body adds licenses and policies |
LICENSE_METADATA_UNSAFE | 422 | a package upload was refused because its own metadata is oversized or ambiguous, so its licenses cannot be checked (License metadata) |
LICENSE_METADATA_UNREADABLE | 422 | a package upload was refused because the repository has a blocking license policy and the package's license cannot be read from its file (License metadata) |
unsafe_artifact | 422 | a license scan or license property change on a stored package whose metadata cannot be read safely |
unreadable_artifact | 422 | a license scan or license property change on a stored package whose file cannot be read while a blocking license policy applies |
LICENSE_POLICY_UNAVAILABLE | 500 | the license policies could not be read, so the upload was refused |
upstream_error | 502 | an upstream (LLM, IdP, proxy) failed |
SSO endpoints add their own codes (oidc_*, saml_*, state_mismatch, ...). Errors raised before a handler runs use simpler bodies: 401 {"error":"unauthorized"}, 403 {"error":"forbidden","action":"...","path":"..."}, 403 {"error":"csrf_protection","message":"..."}, 400 {"error":"invalid_path"}. Clients should key on the HTTP status first and on code when present.
Pagination
List endpoints do not share one scheme. Always check the spec for the endpoint:
| Style | Endpoints | Request | Response keys |
|---|---|---|---|
page + limit | repositories, repository packages, bundles | page from 1, limit default 25, max 100 | total, page, limit, plus the list (repositories, packages, ...) |
page + per_page | users, admin tokens, admin audit | per_page default 25 (users 50), max 100 (users 200) | data, page, per_page, total |
limit + offset | AI analyses | limit default 20, max 100; offset from 0 | analyses, total, limit, offset |
Out-of-range values are clamped rather than rejected. The trash lists (/trash, /repositories/{name}/trash) use the page + limit style with the list under items.
Repository settings and deletion
POST /api/v1/repositories and PATCH /api/v1/repository/{name} accept two per-repository settings for the trash and are returned in repository responses:
| Field | Type | Meaning |
|---|---|---|
trash_retention_days | integer >= 0, or null | Days deleted items of this repository stay restorable. null uses the server default (OPENARTIFACT_TRASH_RETENTION, 7); 0 deletes permanently and immediately. Negative values are 400 |
immutable_releases | bool | When true, deleting or overwriting a released version answers 409 unless an admin passes ?force=true (per-format definition) |
merge_proxy_versions | bool | Group repositories only, default false; global admins only (403 for others). By default hosted members win a package name and proxy versions of the same name are hidden; true merges them (semantics) |
sign_packages | bool | Hosted rpm repositories only, default false (400 otherwise when true). When true, uploaded RPMs are signed with the active GPG key; existing packages are not re-signed |
federated | bool | Hosted repositories of a federation-capable format only, default false; global admins only. Starts the replication log (federation); 400 when federation is off on the server or the repository is not eligible |
DELETE /api/v1/repository/{name} requires the repository name as confirmation, either as a query parameter or a JSON body; without it the answer is 400 confirmation_required:
curl -X DELETE "$HOST/api/v1/repository/old-repo?confirm=old-repo" -H "Authorization: Bearer <TOKEN>"
curl -X DELETE "$HOST/api/v1/repository/old-repo" -H "Authorization: Bearer <TOKEN>" \
-H 'Content-Type: application/json' -d '{"confirm":"old-repo"}'The repository then moves to the trash for its retention period (unless that is 0); its name stays reserved until it is purged or restored.
Trash API
| Method and path | Auth | Purpose |
|---|---|---|
GET /api/v1/trash | admin | List trashed items of all repositories, trashed repositories included. Query: page, limit, kind, repository |
GET /api/v1/repositories/{name}/trash | delete on the repository | List that repository's trashed items. Query: page, limit, kind |
POST /api/v1/trash/{kind}/{id}/restore | admin | Restore an item or a repository; 200 with the item |
POST /api/v1/repositories/{name}/trash/{kind}/{id}/restore | delete on the repository | Restore an item of that repository (not for kind repository) |
DELETE /api/v1/trash/{kind}/{id} | admin | Purge now (permanent); 204 |
DELETE /api/v1/repositories/{name}/trash | manage on the repository (or admin) | Empty that repository's trash (items only; at most 1000 per call); 200 {"purged":N,"remaining":M} |
An item is {"id","kind","repository_id","repository","format","name","version","size_bytes","deleted_at","deleted_by","deleted_by_name","expires_at","purge_at"} (expires_at and purge_at hold the same purge time; deleted_by_name is the display name of the deleter); a list is {"items":[...],"total":N,"page":P,"limit":L}. Restore conflicts are 409 (trash_conflict, repository_in_trash). Kinds, examples and semantics: Trash.
Tokens, activity and usage
| Method and path | Auth | Purpose |
|---|---|---|
GET /api/v1/tokens | any signed-in user | Personal, project and federation tokens in one list, {"tokens":[...],"total":N,"page":P,"limit":L}, newest first. Query: type (personal, project, federation), project (name), expires_before (RFC 3339 or YYYY-MM-DD), q (name or owner substring), page, limit (default 25, at most 100). A global admin sees every token; anyone else sees their own personal tokens plus the project and federation tokens of the projects they maintain or own, and needs a session (an API token gets 403). Each token has id, type, name, owner, created_at, expired and, where they apply, project, repository, role, scopes, expires_at, last_used_at; the secret is never returned and revoked tokens are not listed. Revoke with DELETE /auth/tokens/{id}, /admin/tokens/{id}, /projects/{project}/tokens/{id} or /repositories/{name}/federation/tokens/{id}. |
GET /api/v1/repositories/{name}/activity | admin, or maintainer or owner of the repository's project | Audit events recorded against the repository, newest first, with actor_name and subject resolved. Query: package_name, version, actor, action, cursor, limit (default 25, at most 100). Keyset paging: {"data":[...],"per_page":N,"has_more":b,"next_cursor":"..."}. |
GET /api/v1/repositories/{name}/usage | admin, or maintainer or owner of the repository's project | package_count, version_count, size_bytes, last_published_at and the repository quota (limit_bytes and soft_limit_bytes are absent when unlimited, used_bytes, usage_percent, exceeded). It scans the repository's package rows: read it for one repository on demand. |
GET /api/v1/admin/quotas | admin | Every live repository and every project with its quota and live usage in one request, largest usage first: {"quotas":[{"scope","name","project?","limit_bytes?","used_bytes",...}],"total","page","limit"}. Query: scope, limited=true, q, page, limit (default 500, at most 2000). |
Audit entries of GET /admin/audit and of the project and repository activity feeds carry actor_name (the recorded name, else the user's current name, else system) and subject (kind, name, and repository, project or version where they apply), resolved from ids with one batched lookup per page; a subject that no longer exists keeps the name the event recorded, else its name is null. The export formats are unchanged. GET /api/v1/projects also returns member_count, usage.size_bytes and, for a project with a size limit, quota.limit_bytes, and accepts q.
Route groups
Prefix /api/v1. Most groups are admin-only (the gate above); see the spec for methods, parameters and schemas.
| Prefix | Purpose |
|---|---|
/auth | login, logout, me, change-password, own API tokens (/auth/tokens), SSO login and callbacks (OIDC, SAML) |
/repositories, /repository/{name} | list and create; get, update and delete use the singular /repository/{name} path. The optional presign_downloads field (true, false, null to inherit) controls presigned downloads per repository. GET /repositories returns, on every item, stats (package_count, size_bytes, last_published_at, security with critical/high/medium/low/secrets/license_violations/scanned_at, and quota with limit_bytes (absent when unlimited) and used_bytes), computed with one grouped query per page. It accepts q (name substring), flagged=true (an open critical or high vulnerability, a secret finding or a quarantined result), near_quota=true with quota_threshold (default 0.8), unscanned=true and sort (name, updated, last_published, size); the filters are ANDed, total, page and limit stay |
/repositories/{name}/packages, /debs, /comps | DELETE /packages/{id} deletes a row of any hosted format (trash-aware); list (GET /packages accepts format, package_name (an exact listed-name match returning every version of one package), q (case-insensitive substring of name or version, max 200 characters) and sort (created, default, or name); every row carries security (critical, high, medium, low) and scanned_at of its latest scans; group=package returns one row per package instead, {total, page, limit, packages:[{name, format, latest_version, version_count, size_bytes, updated_at, security, scanned_at}]} with the scan state of the latest version, and with flagged=true only the packages whose latest version has a critical or high vulnerability, a secret finding or a quarantined result; GET /repositories/{name}/packages/{id} returns one version with its metadata, dependencies and files), download and delete packages; upload (debs for Debian, packages for RPM) |
/repositories/{name}/uploads, /repositories/{name}/uploads/{id}/{complete,parts,abort} | POST (write): presigned direct-to-backend upload for hosted raw repositories on every storage backend (S3/GCS/Azure URLs; HMAC-signed /upload-relay/ URLs served by OpenArtifact for filesystem and OCI storage, which need baseUrl). Above 5 GiB (up to 5 TiB) create with "multipart": true (optional part_size), fetch part URLs with .../parts ({"part_numbers": [...]}, at most 100 per call) and discard with .../abort (204). The first call returns a presigned PUT URL (Cache-Control: no-store; 501 when the backend has no usable staging area, 429 with Retry-After past presignMaxPendingUploads). The second, callable by the creator or an admin and idempotent (201 first, 200 on retry), reads the object back, verifies size and sha256 and records the artifact. complete errors: 403 not creator/admin; 404 unknown; 409 with code upload_missing (nothing uploaded yet), upload_incomplete (a part of a multipart upload is missing), upload_in_progress (retry after Retry-After) or upload_changed (staging object re-uploaded during verification); 410 expired; 422 size or sha256 mismatch. See Presigned uploads |
/repositories/{name}/members | member repositories of a group repository; GET is also open to maintainers and owners of the repository's project, PUT stays admin-only |
/trash, /repositories/{name}/trash | trash: list, restore and purge deleted items and repositories |
/repositories/{name}/{cleanup-policies,quota,webhooks} (cleanup policies also expose POST .../{id}/dry-run, as do project default policies) | cleanup and quotas, per-repository webhooks |
/projects/{project}/activity | audit events of the project and its repositories for every member, newest first, keyset paging (cursor, has_more; no total) and no client addresses; see project activity |
/projects/{project}/activity/export | the same events as a JSONL or CSV download (format, since, until, actor, action) for every member, with the same token-event and client-address rules; see audit export |
/projects/{project}/{usage,quota,cleanup-policies} | project storage usage, the project quota (PUT is global-admin only) and default cleanup policies; access follows the caller's project role |
/repositories/{name}/registry-gc | POST (admin): run Docker registry garbage collection, optionally as a dry run |
/repositories/{name}/{sbom,vex} | SBOMs and VEX; ?attest=true|bundle on an SBOM download or the VEX document returns a signed in-toto attestation |
/.well-known/openartifact/attestation-key.pem, /admin/attestation | the public key that verifies those attestations (unauthenticated GET), and its id and algorithm (admin) |
/repositories/{name}/attestation-attach, /repositories/{name}/sbom/{id}/attach, /repositories/{name}/vex/attach | admin: the switch that attaches signed SBOM and VEX attestations to docker images as OCI referrers, and the on-demand attach |
/admin/dependency-track, /repositories/{name}/dependency-track[/test,/push,/pushes] | admin: the global and per-repository Dependency-Track settings (the API key is write-only), connection tests, manual pushes and the push state |
/repositories/{name}/{vuln,license,secrets} | scanning (scans, quarantine, license policies) |
/vuln/scans, /vuln/scans/summary, /vuln/scans/export | GET (admin): vulnerability scans of all repositories, worst first, keyset paging (cursor, has_more; no total); /vuln/scans/export, …/vuln/scans/{id}/export and …/secrets/scans[/{id}]/export download SARIF, CSV or JSON |
/security/findings, /security/findings/summary, /security/findings/export, /security/health, /repositories/{name}/security/summary | GET: security findings of all repositories and finding types, scoped to global admins and the projects the caller maintains or owns (keyset paging: cursor, has_more); export as CSV, JSON or CycloneDX VEX; /security/health (admin) for scanner and integration health; the repository summary (repo read permission) returns counts only |
/repositories/{name}/artifacts/... | properties and property search |
/repositories/{name}/packages/{id}/{delta,deltas} | binary deltas |
/repositories/{name}/ai-analysis | AI analysis |
/packages/recent | GET: the newest package versions of the repositories the caller can read, {packages:[{repository, format, name, version, created_at}]}; limit (default 10, max 50); mine=true keeps the repositories the caller can write to, since the server records no publisher |
/search | search packages and repositories. Query parameters q, format, repo, limit (default 25, max 100), offset and group=package (one hit per package, its latest version, total counts packages). Hits are ranked: exact name, names starting with the query, other matches, then newest first. Returns {total, packages, repositories}; 400 query_too_broad above 10000 matches, 400 invalid_group for another group. group=package hits carry repository, format, name, latest_version and version_count; flagged=true keeps the packages with a critical or high vulnerability, a secret finding or a quarantined result. checksum=<hex> (SHA-256 of 64, SHA-1 of 40 or MD5 of 32 hex characters; case-insensitive) looks up the stored file checksums in the readable repositories and returns {total, packages:[{repository, format, name, version, filename, checksum, algorithm}]} (400 invalid_checksum; 400 query_too_broad when too many files match). SHA-256 matches through the blob store, SHA-1 and MD5 only match formats that record one |
/users, /groups, /permission-targets | identity and RBAC objects. GET /permission-targets/{id}/assignments (admin) lists the users and groups a target is assigned to as {data:[{principal_type, principal_id, principal_name}]}, sorted by type then name |
/admin/declarative | admin only: ownership markers of oa apply, the objects it created or adopted (--prune only deletes marked objects). GET → {items:[{kind, name, object_id, applied_hash, applied_at}]}; PUT {items:[{kind, name, object_id, applied_hash}]} upserts up to 1000 markers in one transaction (400 validation_failed for an unknown kind or a length violation); DELETE ?kind=K&name=N → 204, 404 when absent. Markers are not part of backups: a restored instance has none until the next apply re-adopts the objects |
/projects (with /repositories, /members, /groups and /tokens) | projects: open to every authenticated user and decided per project role, not by global manage (404 for non-members, 403 for members lacking the role). Updates use PATCH for the project and for a member's role |
/webhooks | global webhooks, test, deliveries |
/bundles, /pipelines | release bundles and promotion pipelines |
/admin/{tokens,storage,backup,restore,audit,gpg,sso-providers,ai,license,system} | token administration, storage, backup, audit log (/admin/audit/export streams it as a JSONL or CSV download), GPG signing keys (/admin/gpg/keys: list, import, generate, activate, delete, export; see signing), SSO providers, AI settings, global license policies, detailed system info |
/admin/tiering/{tiers,policies,dry-run,run,status} | storage tiers: tiers with placement stats, lifecycle policies (CRUD), dry run (POST /admin/tiering/dry-run: counts, bytes and a sample of what would move, no changes), mover trigger and status |
/system/info | version and build information |
/admin/pprof/ is also registered when server.pprofEnabled is set (admin only; for diagnostics, see performance).
Regenerating the web client types
The spec is OpenAPI 3.1.1 (JSON Schema 2020-12): nullable values are type: [T, "null"] or anyOf with {"type": "null"}, not the 3.0 nullable keyword. go test ./internal/api validates it against the official 3.1 meta-schema.
The SPA's types are generated from the spec. After changing routes or schemas, keep internal/api/openapi.json in sync with the handlers, then:
cd web && bun run generate-api # writes web/src/api/generatedCalls from the SPA go through web/src/api/client.ts, which adds the CSRF header and handles 401.