Skip to content

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 ​

URLContent
/api/v1/openapi.jsonOpenAPI 3.1 spec (embedded from internal/api/openapi.json)
/api/v1/openapi.yamlsame spec rendered as YAML
/api/referenceInteractive 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_session cookie and the page adds the X-CSRF-Token header (read from the oa_csrf cookie) to POST, PUT, PATCH and DELETE, so mutations are not rejected with 403 csrf_protection.
  • Token: open the authentication panel, choose bearerAuth and 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 are access (management API, per the owner's permissions) or deploy (repository credentials; never granted manage, so no /api/v1 management calls).
  • Session cookie: POST /api/v1/auth/login ({"username","password"}) sets oa_session (HttpOnly, SameSite=Lax) and a readable oa_csrf cookie. State-changing requests (POST, PUT, PATCH, DELETE) on a session must echo it in X-CSRF-Token, otherwise 403 {"error":"csrf_protection",...}. Login is rate limited (429 rate_limited with Retry-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_password gets 403 password_change_required everywhere except change-password and logout.
  • Gate: every /api/v1/* route requires the global manage permission, except GET /repositories, GET /packages/recent and GET /search (any authenticated user; results are filtered per repository), /projects/** (decided by the caller's project role; unlisted sub-paths still need manage), PATCH/DELETE /repository/{name}, /repositories/{name}/cleanup-policies... and /repositories/{name}/quota (manage, or maintainer/owner of the repository's project), GET /repository/{name} (read on that repository), /repositories/{name}/{packages,debs,comps} and /repositories/{name}/trash... (read/write/delete, or delete, on that repository; DELETE .../trash needs manage on it). Anonymous callers can only read public repositories (or what allowAnonymousRead exposes).

Example: token and repository ​

Tokens are minted from a login session, then used for everything else:

sh
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 are null.
  • 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:

json
{"error": "name must be 1-100 alphanumeric/hyphen/underscore characters", "code": "validation_failed"}
codeStatusMeaning
bad_request400malformed JSON or parameters
validation_failed400well-formed request with invalid values
unauthorized401authentication required or invalid
invalid_credentials401wrong username/password
forbidden403authenticated but not permitted
admin_required403administrator permission needed
not_found404missing resource, or a repository the caller may not see
conflict409duplicate or state conflict
immutable_release409a bundle promotion would overwrite a released version in a repository with immutable releases (promotion)
artifact_conflict409a bundle promotion found different content at the same coordinates in the target
artifact_missing409a bundle item is no longer stored in its source repository
quota_exceeded507the target repository's quota cannot hold the promoted artifacts
ARTIFACT_QUARANTINED403a bundle promotion was refused because a source artifact is quarantined
payload_too_large413body over the endpoint limit
rate_limited, too_many_scans429throttled
not_implemented501endpoint exists but the feature is not available
internal_error500server error; the cause is logged, never returned
LICENSE_BLOCKED403a package upload or bundle promotion was rejected by a license policy with action block; the body adds licenses and policies
LICENSE_METADATA_UNSAFE422a package upload was refused because its own metadata is oversized or ambiguous, so its licenses cannot be checked (License metadata)
LICENSE_METADATA_UNREADABLE422a 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_artifact422a license scan or license property change on a stored package whose metadata cannot be read safely
unreadable_artifact422a license scan or license property change on a stored package whose file cannot be read while a blocking license policy applies
LICENSE_POLICY_UNAVAILABLE500the license policies could not be read, so the upload was refused
upstream_error502an 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:

StyleEndpointsRequestResponse keys
page + limitrepositories, repository packages, bundlespage from 1, limit default 25, max 100total, page, limit, plus the list (repositories, packages, ...)
page + per_pageusers, admin tokens, admin auditper_page default 25 (users 50), max 100 (users 200)data, page, per_page, total
limit + offsetAI analyseslimit default 20, max 100; offset from 0analyses, 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:

FieldTypeMeaning
trash_retention_daysinteger >= 0, or nullDays 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_releasesboolWhen true, deleting or overwriting a released version answers 409 unless an admin passes ?force=true (per-format definition)
merge_proxy_versionsboolGroup 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_packagesboolHosted 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
federatedboolHosted 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:

sh
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 pathAuthPurpose
GET /api/v1/trashadminList trashed items of all repositories, trashed repositories included. Query: page, limit, kind, repository
GET /api/v1/repositories/{name}/trashdelete on the repositoryList that repository's trashed items. Query: page, limit, kind
POST /api/v1/trash/{kind}/{id}/restoreadminRestore an item or a repository; 200 with the item
POST /api/v1/repositories/{name}/trash/{kind}/{id}/restoredelete on the repositoryRestore an item of that repository (not for kind repository)
DELETE /api/v1/trash/{kind}/{id}adminPurge now (permanent); 204
DELETE /api/v1/repositories/{name}/trashmanage 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 pathAuthPurpose
GET /api/v1/tokensany signed-in userPersonal, 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}/activityadmin, or maintainer or owner of the repository's projectAudit 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}/usageadmin, or maintainer or owner of the repository's projectpackage_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/quotasadminEvery 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.

PrefixPurpose
/authlogin, 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, /compsDELETE /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}/membersmember 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}/trashtrash: 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}/activityaudit 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/exportthe 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-gcPOST (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/attestationthe 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/attachadmin: 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/exportGET (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/summaryGET: 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-analysisAI analysis
/packages/recentGET: 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
/searchsearch 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-targetsidentity 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/declarativeadmin 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
/webhooksglobal webhooks, test, deliveries
/bundles, /pipelinesrelease 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/infoversion 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:

sh
cd web && bun run generate-api    # writes web/src/api/generated

Calls from the SPA go through web/src/api/client.ts, which adds the CSRF header and handles 401.

Free software under the AGPL-3.0-or-later.