Documentation

API reference

A guided tour of the API. The exhaustive list is /openapi.json.

Source: api.packr.blueforge.studio/-/docs/api

API Reference

Base URL: https://api.packr.blueforge.studio (production) or http://localhost:4873 (local).

This is the guided tour, not the exhaustive list. The complete endpoint list is GET /openapi.json, generated from the registry's route table and checked against it by a test, so it cannot drift from what the server actually serves. This document explains the endpoints worth explaining and the workflows they add up to.

That split exists because the previous arrangement failed twice over: a hand-maintained spec claimed 16 endpoints against roughly 110 real ones and reported version 0.5.0 while production ran 0.29.x, and this file documented a dist-tag path the registry never served. Treat anything below that reads like a complete inventory as a curated selection instead.


Authentication

JWT Bearer Token

Most write operations require a JWT token obtained via POST /-/v1/login:

Authorization: Bearer <token>

npmrc Configuration

For CI, add to ~/.npmrc:

//api.packr.blueforge.studio/:_authToken=<token>
@myorg:registry=https://api.packr.blueforge.studio

Or use environment variable PACKR_TOKEN.


Endpoints

Health Check

GET /health

Returns {"status":"ok"}. No authentication required.


Ping (Build Identity)

GET /-/ping

Returns the registry's build identity and an absolute URL to the changelog. Useful for service discovery and "what version is running" dashboards. No authentication required.

Response 200:

{
  "status": "ok",
  "version": "v0.8.0",
  "commit": "07ad5ba8",
  "build_date": "2026-06-27T14:23:11Z",
  "go_version": "go1.25.0",
  "region": "ams",
  "changelog_url": "https://packr.blueforge.studio/-/changelog"
}

version, commit, and build_date are injected at build time via go build -ldflags "-X github.com/packr-registry/packr/internal/version.X=Y". They default to dev / unknown for local go run builds. region is read from FLY_REGION (empty on local).


Changelog

GET /-/changelog

Returns the contents of CHANGELOG.md as text/markdown. Cached for 5 minutes. No authentication required.

The path is configurable via CHANGELOG_FILE env var; defaults to /CHANGELOG.md (bundled into the Docker image at build time).

Response 503 if the file does not exist at any of the resolved paths ($CHANGELOG_FILE, /CHANGELOG.md, /app/CHANGELOG.md, ./CHANGELOG.md).


OpenAPI Spec

GET /openapi.json

Returns the OpenAPI 3.0 specification. No authentication required.


Token Introspection (Whoami)

GET /-/whoami
Authorization: Bearer <token>

Returns the claims from a valid token. Use this to debug CI auth issues — verify the token has the expected scopes, permissions, and has not expired.

Accepts both kinds of credential, and reports which one was recognised via the auth field:

authMeaning
jwtSignature verified against the current JWT_SECRET. Works everywhere, including the admin API.
tokenResolved by hash lookup in the tokens table — the path publish and install use. Works for publishing; will not work on /api/v1/admin/*.

This is the supported way to check whether a CI token still works without publishing something. A 401 that names the signing key means the token was issued before the last JWT_SECRET rotation: it will still publish but cannot use the admin API, and should be re-issued with packr-cli login.

Response (200 OK):

{
  "user_id": "default:blueforge-ci",
  "org_id": "default",
  "scopes": ["@blueforge-studio"],
  "pkg_limit": 100,
  "permissions": ["read", "publish"],
  "issued_at": "2026-05-01T10:00:00Z",
  "expires_at": "2026-06-01T10:00:00Z"
}
  • 401 — Missing token
  • 401 — Invalid or expired token

Login / Get Token

POST /-/v1/login
Content-Type: application/json

{
  "name": "username",
  "password": "Password@123"
}

Response (200 OK):

{
  "token": "eyJhbGciOiJIUzI1NiIs..."
}

Errors:

  • 401 — Invalid credentials
  • 429 — Rate limit exceeded (10 req/min/IP)

Notes:

  • If ALLOW_REGISTRATION=false (default), user must already exist. Create users via CLI: packr-cli user create <name> --password <pass>
  • If ALLOW_REGISTRATION=true, new users are auto-created on first login

Publish Package

PUT /[:scope/]:pkg
Authorization: Bearer <token>
Content-Type: application/octet-stream (npm CLI format)

<metadata JSON>\n\n<tarball gzipped>

Or plain JSON (curl):

PUT /[:scope/]:pkg
Authorization: Bearer <token>
Content-Type: application/json

{
  "name": "@scope/pkg",
  "version": "1.0.0",
  "dependencies": {...}
}

Response (200 OK):

{"ok": "true"}

Errors:

  • 400 — Invalid package name, version, or tarball
  • 401 — Missing or invalid token
  • 403 — Not authorized to publish to this package
  • 413 — Tarball too large (default: 5 MB, configurable via MAX_TARBALL_SIZE)
  • 429 — Rate limit exceeded (30 req/min/IP)

npm CLI Example:

npm publish --registry https://api.packr.blueforge.studio

Get Package Metadata

GET /[:scope/]:pkg

Response (200 OK):

{
  "name": "@scope/pkg",
  "dist-tags": {"latest": "1.0.0"},
  "versions": {
    "1.0.0": {
      "name": "@scope/pkg",
      "version": "1.0.0",
      "dist": {"tarball": "https://api.packr.blueforge.studio/@scope/pkg/-/package/1.0.0.tgz"}
    }
  },
  "time": {"modified": "2026-04-09T12:00:00Z"}
}

Errors:

  • 404 — Package not found

Get Specific Version

GET /[:scope/]:pkg/:version

Response (200 OK): Version metadata object (same format as version in package metadata).

Errors:

  • 404 — Package or version not found

Download Tarball

The registry accepts multiple tarball URL formats to interoperate with pnpm, Yarn, npm, and any client that resolves dependencies through the npm registry protocol.

FormatURL patternExample
Packr scoped/{scope}/{name}/-/package/{filename}.tgz/@blueforge-studio/foo/-/package/foo-1.0.0.tgz
Packr unscoped/{name}/-/package/{filename}.tgz/foo/-/package/foo-1.0.0.tgz
npm registry scoped/@scope/name/-/name-version.tgz/@blueforge-studio/foo/-/foo-1.0.0.tgz
npm top-level/-/name-version.tgz/-/foo-1.0.0.tgz (pnpm lockfile)

For the npm top-level format, the registry looks up the package by name across all scopes to resolve the canonical scope. When TARBALL_DIRECT_DOWNLOAD=true, the registry returns a 307 redirect to a presigned S3/B2 URL instead of proxying the bytes (disabled by default — Docker build contexts and firewalled clients cannot always reach S3 endpoints directly).

GET /[:scope/]:pkg/-/package/:version.tgz

Response (200 OK): .tgz tarball file stream.

Errors:

  • 404 — Package or version not found (or proxy upstream also 404)
  • 502 — Storage backend failure (STORAGE_ERROR)
  • 504 — Upstream proxy timed out (GATEWAY_TIMEOUT)

Unpublish Package

DELETE /-/v1/unpublish
Authorization: Bearer <token>
Content-Type: application/json

{
  "name": "@scope/pkg",
  "version": "1.0.0"
}

Constraints:

  • Only the package owner can unpublish
  • Version must have been published within the last 72 hours
  • If unpublishing the last version, the entire package is deleted

Response (200 OK):

{
  "ok": true,
  "name": "@scope/pkg",
  "version": "1.0.0"
}

Errors:

  • 400 — Missing name or version, or outside 72-hour window
  • 401 — Missing or invalid token
  • 403 — Not the package owner
  • 404 — Package or version not found

Deprecate Package

PUT /-/v1/deprecate
Authorization: Bearer <token>
Content-Type: application/json

{
  "name": "@scope/pkg",
  "version": "1.0.0",
  "message": "Use @scope/pkg-v2 instead"
}

Response (200 OK):

{"ok": true}

Errors:

  • 400 — Missing required fields
  • 401 — Missing or invalid token
  • 403 — Not the package owner
  • 404 — Package not found

Dist-Tags

These are the paths npm dist-tag actually calls. A scoped package is one percent-encoded segment: @acme%2Fwidget.

GET    /-/package/:pkg/dist-tags
PUT    /-/package/:pkg/dist-tags/:tag     body: "1.2.3"  (a JSON string)
DELETE /-/package/:pkg/dist-tags/:tag

Response (200 OK) — always the package's full tag map:

{"latest": "1.0.0", "next": "2.0.0-beta.1"}

Writes require package ownership, not merely a valid token: moving a tag decides what every npm install of the package resolves to.

Three rules the registry enforces:

  • A tag may not point at an unpublished version. Stored, it would surface as a 404 on some later install, far from the mistake that caused it.
  • A tag name that parses as a version is refused, because pkg@1.0.0 cannot distinguish a tag from a version.
  • latest cannot be removed, only repointed. Removing it leaves a plain npm install with nothing to resolve.

Earlier versions of this document described GET /:scope/:pkg/-/package/dist-tags. That path was never served, and npm dist-tag answered 404 against this registry until it was implemented on the paths above.


Search Packages

GET /-/search/:scope?q=:query

Parameters:

  • q — Search query (matches package name)
  • sort — Sort by name or updated (default: name)

Response (200 OK):

{
  "packages": [
    {
      "name": "@scope/pkg",
      "version": "1.0.0",
      "description": "A package",
      "updatedAt": "2026-04-09T12:00:00Z"
    }
  ],
  "total": 1
}

Public Package Browse

GET /api/v1/public/packages?ecosystem=&scope=&limit=&offset= No auth. Returns {packages, total, limit, offset} — only packages whose visibility is public.

GET /api/v1/search?q=<query>&ecosystem=<eco>&limit=<n>&offset=<n>

No auth (optional credential — surfaces private packages the caller may read). Searches every ecosystem in the unified packages table by name, scope, and description.

  • q (required) — substring query; %/_ are matched literally.
  • ecosystem (optional) — one of npm|go|cargo|jvm|maven|pypi|nuget; omitted = all.
  • limit/offset — default 20/0; limit capped at 100.

Anonymous calls see only public packages; a credential additionally surfaces private packages the caller may read (never 403s).

Response (200 OK):

{
  "objects": [
    {
      "package": {
        "ecosystem": "npm",
        "scope": "@acme",
        "name": "foo",
        "version": "1.2.3",
        "description": "Does the thing",
        "visibility": "public"
      },
      "score": { "exact": true, "prefix": false, "foundIn": ["name"] }
    }
  ],
  "total": 7,
  "limit": 20,
  "offset": 0
}

Set Package Visibility (admin)

POST /api/v1/admin/packages/visibility Body: {"ecosystem":"npm","scope":"@acme","name":"my-pkg","visibility":"private"} Auth: admin session (cookie) or JWT bearer — the caller must own the package (or send X-Internal-Secret). Valid visibility values: public, private. 404 if the package does not exist.


Password Reset Request

POST /-/v1/password-reset
Content-Type: application/json

{
  "name": "username"
}

Response (200 OK):

{"ok": true}

Errors:

  • 429 — Rate limit exceeded (10 req/min/IP)

Notes: Sends a reset email if email is configured. Otherwise returns immediately.


Password Reset Confirm

POST /-/v1/password-reset/confirm
Content-Type: application/json

{
  "token": "reset-token-from-email",
  "password": "NewPassword@123"
}

Response (200 OK):

{"ok": true}

Errors:

  • 400 — Invalid or expired token
  • 400 — Password does not meet requirements (min 10 chars, upper+lower+digit+special)

OAuth Device Flow

The OAuth device flow (RFC 8628) enables headless and CLI login without a browser on the authenticating machine. See docs/06-oauth.md for architecture details.

List Available OAuth Providers

GET /-/v1/device/authorize

Returns the list of configured OAuth providers. No request body required.

Response (200 OK):

{
  "providers": ["github", "google", "gitlab"]
}

Start Device Flow

POST /-/v1/device/authorize
Content-Type: application/json

{
  "provider": "github"
}

Response (200 OK):

{
  "device_code": "d1e2v3i4c5e6c7o8d9e0",
  "user_code": "ABCD-1234",
  "verification_uri": "https://api.packr.blueforge.studio/verify",
  "verification_uri_complete": "https://api.packr.blueforge.studio/verify?code=ABCD-1234",
  "expires_in": 900,
  "interval": 5
}

Errors:

  • 400 — Missing or unsupported provider
  • 429 — Rate limit exceeded (5 req/min/IP)

The user must open verification_uri_complete (or visit verification_uri and enter user_code) within expires_in seconds (15 minutes).


Poll for Access Token

POST /-/v1/device/token
Content-Type: application/json

{
  "device_code": "d1e2v3i4c5e6c7o8d9e0"
}

Poll this endpoint at the interval (default 5 seconds) returned by the authorize call.

Response (200 OK — user completed login):

{
  "access_token": "eyJhbGciOiJIUzI1NiIs...",
  "token_type": "Bearer",
  "expires_in": 2592000
}

Polling States (400 errors — keep polling unless expired_token):

Error codeMeaning
authorization_pendingUser hasn't completed login yet. Keep polling.
slow_downPolling too fast. Increase interval by 5s.
expired_tokenCode expired (15 min). Start a new flow.
access_deniedUser denied the request. Stop polling.

AI Agent Endpoints

These endpoints honour package visibility exactly as the protocol routes do. No credential is required, and an anonymous request is answered with the public packages only: a private package answers 404, and /agent/search omits it. Send a token with read authorization (Authorization: Bearer <token>) to see private packages — packr-cli agent-* uses your stored login, and the SDK and MCP server send PACKR_TOKEN when it is set.

Package Info

GET /agent/:pkg

Returns AI-optimized package info with quality score and download count.

Response (200 OK):

{
  "name": "@scope/pkg",
  "version": "1.0.0",
  "quality": 85,
  "downloads": 1234,
  "description": "A package",
  "dependencies": ["dep-a", "dep-b"]
}

GET /agent/search?q=:capability

Search packages by capability keyword.

Response (200 OK):

{
  "packages": [...],
  "query": "auth",
  "count": 5
}

Compare Packages

GET /agent/compare?packages=:pkg1,:pkg2

Side-by-side comparison with recommendation.

Response (200 OK):

{
  "packages": [
    {"name": "@scope/pkg-a", "quality": 85, "downloads": 500},
    {"name": "@scope/pkg-b", "quality": 72, "downloads": 1200}
  ],
  "recommended": "@scope/pkg-a",
  "count": 2
}

Dependency Graph

GET /agent/:pkg/deps

Returns the dependency graph for a package.

Response (200 OK):

{
  "name": "@scope/pkg",
  "version": "1.0.0",
  "dependencies": [
    {"name": "@scope/dep-a", "version": "^1.0.0"},
    {"name": "@scope/dep-b", "version": "^2.0.0"}
  ],
  "dependents": [
    {"name": "@scope/other-pkg", "version": "^1.0.0"}
  ]
}

Admin API

Admin endpoints require either:

  • X-Internal-Secret: <INTERNAL_API_SECRET> header (for Next.js dashboard → Go backend)
  • Session cookie (for dashboard browser sessions)

Create Admin Session

POST /api/v1/admin/sessions
X-Internal-Secret: <secret>
Content-Type: application/json

{
  "name": "username",
  "password": "Password@123"
}

Response (200 OK):

{"ok": true}

Sets packr_session cookie.


Create Token

POST /api/v1/admin/tokens
Cookie: packr_session=<session>
Content-Type: application/json

{
  "name": "ci-publish-token",
  "role": "ci-publish",
  "scopes": ["@blueforge-studio"],
  "expires_in_days": 30
}

Role values: ci-readonly, ci-publish, maintainer, admin

Response (201 Created):

{
  "id": 42,
  "name": "ci-publish-token",
  "token": "eyJhbGciOiJIUzI1NiIs...",
  "role": "ci-publish",
  "permissions": ["read", "publish"],
  "scopes": ["@blueforge-studio"],
  "expires_at": "2026-05-10T00:00:00Z",
  "created_at": "2026-04-10T00:00:00Z"
}

The raw token value is only returned at creation time. Store it securely immediately.

Errors:

  • 400 — Invalid role or missing name
  • 401 — Not authenticated

List Tokens

GET /api/v1/admin/tokens
Cookie: packr_session=<session>

Response (200 OK):

{
  "tokens": [
    {
      "id": 1,
      "name": "github-actions",
      "role": "ci-publish",
      "permissions": ["read", "publish"],
      "scopes": ["@blueforge-studio"],
      "created_at": "2026-04-09T12:00:00Z",
      "last_used_at": "2026-04-09T14:00:00Z",
      "expires_at": "2026-05-09T12:00:00Z"
    }
  ]
}

Revoke Token

DELETE /api/v1/admin/users/me/tokens/:id
Cookie: packr_session=<session>

Deletes a token you own. Requires a session or a JWT whose signature verifies against the current JWT_SECRET — see Revoke Token (operator secret) when that is not available.

Response (200 OK):

{"ok": true}

Revoke Token (operator secret)

POST /api/v1/admin/tokens/revoke
X-Super-Admin-Token: <token>      (or X-Internal-Secret: <secret>)

Gated by a shared secret rather than by a session, because the case it exists for is a leaked credential: whoever is revoking may not be able to authenticate as its owner, and the leaked token must obviously not be an acceptable credential for revoking itself.

Accepts exactly one of:

FieldEffect
jtiAdds that token id to the revocation denylist.
tokenThe leaked value itself; its jti is read from the claims (signature not required, so a token signed with a rotated key can still be revoked).
idDeletes that row from the tokens table.

Use id for a credential issued before jti claims existed. It has no jti to deny, and it authenticates by sha256 hash lookup rather than by signature, so deleting the row is what actually stops it. Before this existed the only remedy was rotating JWT_SECRET, which invalidates every other token at the same time.

Request:

{"id": 19, "reason": "exposed in a log"}

Response (200 OK):

{"revoked": "19", "reason": "exposed in a log"}
  • 400 — none of jti, token, or id supplied, or the row does not exist
  • 401 — missing or incorrect shared secret
packr-cli token revoke 19 --super-admin-token <token> --reason "exposed in a log"

List Revoked Tokens

GET /api/v1/admin/tokens/revoked
X-Super-Admin-Token: <token>

Returns the jti denylist. Note that tokens revoked by row id are deleted rather than denylisted, so they do not appear here — they are simply gone from the tokens table.


List Webhooks

GET /api/v1/admin/webhooks
Cookie: packr_session=<session>

Response (200 OK):

{
  "webhooks": [
    {
      "id": 1,
      "url": "https://example.com/webhook",
      "events": ["package.published", "token.created"],
      "created_at": "2026-04-09T12:00:00Z"
    }
  ]
}

Create Webhook

POST /api/v1/admin/webhooks
Cookie: packr_session=<session>
Content-Type: application/json

{
  "url": "https://example.com/webhook",
  "secret": "webhook-secret",
  "events": ["package.published"]
}

Response (201 Created):

{
  "id": 1,
  "url": "https://example.com/webhook",
  "secret": "webhook-secret",
  "events": ["package.published"]
}

Delete Webhook

DELETE /api/v1/admin/webhooks/:id
Cookie: packr_session=<session>

Response (200 OK):

{"ok": true}

Webhook Deliveries

Webhook delivery attempts are now logged and retried automatically with exponential backoff.

Retry schedule: 1 minute, 5 minutes, 30 minutes, 2 hours, 24 hours (max 5 attempts).

Failed deliveries are retried automatically. After 5 failures, the delivery is marked as permanently failed.


Audit Log

GET /api/v1/admin/audit
Cookie: packr_session=<session>

Query parameters: limit (default 50, max 200), offset (default 0)

Response (200 OK):

{
  "events": [
    {
      "id": 1,
      "action": "package.published",
      "actor_id": 42,
      "actor_name": "kmandrup",
      "resource_type": "package",
      "resource_id": "1",
      "resource_name": "@blueforge-studio/test-pkg",
      "details": "version 1.0.0",
      "created_at": "2026-04-10T12:00:00Z"
    }
  ],
  "limit": 50,
  "offset": 0
}

Event types: package.published, package.unpublished, package.deprecated, token.created, token.revoked, user.created, profile.updated, user.login


Organizations

GET /api/v1/admin/orgs
Cookie: packr_session=<session>

List all organizations.


POST /api/v1/admin/orgs
Cookie: packr_session=<session>
Content-Type: application/json

{
  "slug": "blueforge-studio",
  "display_name": "BlueForge Studio"
}

Create organization. The creator automatically becomes an admin member.

Response (201 Created):

{"ok": true}

GET /api/v1/admin/orgs/:slug/members
Cookie: packr_session=<session>

List members of an organization.


POST /api/v1/admin/orgs/:slug/members
Cookie: packr_session=<session>
Content-Type: application/json

{
  "user_id": 42,
  "role": "maintainer"
}

Add a member to an organization. Valid roles: admin, maintainer, reader


DELETE /api/v1/admin/orgs/:slug/members/:userId
Cookie: packr_session=<session>

Remove a member from an organization.

Response (200 OK):

{"ok": true}

GET /api/v1/admin/users/me/orgs
Cookie: packr_session=<session>

List the current user's organizations.


Org Packages (Public)

GET /api/v1/orgs/:slug/packages

List all packages belonging to an organization. No authentication required.

Query parameters: limit (default 20, max 100), offset (default 0)

Response (200 OK):

{
  "packages": [
    {
      "name": "@blueforge-studio/forge-ui",
      "version": "1.2.0",
      "description": "BlueForge UI component library",
      "updatedAt": "2026-04-10T12:00:00Z"
    }
  ],
  "total": 1,
  "limit": 20,
  "offset": 0
}

Errors:

  • 404 — Organization not found

Package Transfer

POST /api/v1/admin/packages/transfer
Cookie: packr_session=<session>
Content-Type: application/json

{
  "name": "@scope/pkg",
  "to_user": "new-owner-username"
}

Transfer ownership of a package to another user. Only the current package owner or an admin may transfer.

Response (200 OK):

{"ok": true}

Errors:

  • 400 — Missing name or to_user
  • 401 — Not authenticated
  • 403 — Not the package owner or admin
  • 404 — Package or target user not found

Prune Versions Before

POST /api/v1/admin/packages/:scope/:name/prune-before
Cookie: packr_session=<session>
Content-Type: application/json

{
  "before": "1.0.0"
}

Delete all versions of a package with semver strictly less than before. Useful for discarding old CI snapshots after a stable release. Only the package owner or an admin may prune. Versions that fail semver parsing are skipped (not deleted).

X-Internal-Secret header can be used by server-side automation to bypass the ownership check.

Response (200 OK):

{
  "deleted": 12,
  "before": "1.0.0"
}

Errors:

  • 400 — Missing before, or invalid semver
  • 401 — Not authenticated
  • 403 — Not the package owner or admin
  • 404 — Package not found

Prune Keep Latest

POST /api/v1/admin/packages/:scope/:name/prune-keep-latest
Cookie: packr_session=<session>
Content-Type: application/json

{
  "keep": 5
}

Delete all versions of a package except the keep most recent semver releases. Useful for capping package version count and storage. Sorted by semver, not by publish date — pre-release versions are sorted alongside stable releases.

X-Internal-Secret header can be used by server-side automation to bypass the ownership check.

Response (200 OK):

{
  "deleted": 7,
  "kept": 5
}

Errors:

  • 400 — Missing or invalid keep (must be ≥ 0)
  • 401 — Not authenticated
  • 403 — Not the package owner or admin
  • 404 — Package not found

Usage (Raw)

GET /api/v1/admin/usage
Cookie: packr_session=<session>

Returns per-package download counts for the authenticated user's packages.

Query parameters: limit (default 50, max 200), offset (default 0)

Response (200 OK):

{
  "usage": [
    {
      "package": "@scope/pkg",
      "version": "1.0.0",
      "downloads": 42,
      "period": "2026-04"
    }
  ],
  "limit": 50,
  "offset": 0
}

Usage Summary

GET /api/v1/admin/usage/summary
Cookie: packr_session=<session>

Returns aggregate usage totals for the authenticated user, including plan limits.

Response (200 OK):

{
  "total_downloads": 1234,
  "total_packages": 5,
  "plan": "solo",
  "package_limit": 20,
  "bandwidth_bytes": 52428800,
  "period": "2026-04"
}

Subscription

GET /api/v1/admin/users/me/subscription
Cookie: packr_session=<session>

Returns the current user's subscription details from Stripe.

Response (200 OK):

{
  "plan": "solo",
  "status": "active",
  "current_period_end": "2027-04-10T00:00:00Z",
  "cancel_at_period_end": false,
  "portal_url": "https://billing.stripe.com/session/..."
}

Plan values: free, solo, team, enterprise

Status values: active, trialing, past_due, canceled

Notes:

  • portal_url is a short-lived Stripe Customer Portal URL for self-service plan changes, payment method updates, and invoice history.

Errors:

  • 401 — Not authenticated

Profile Endpoints

Get Own Profile

GET /api/v1/admin/users/me/profile
Cookie: packr_session=<session>

Response (200 OK):

{
  "username": "kristian",
  "display_name": "Kristian Mandrup",
  "bio": "Open source developer",
  "website": "https://example.com",
  "avatar_url": "https://avatars.githubusercontent.com/u/123456",
  "created_at": "2026-01-01T00:00:00Z"
}

Update Own Profile

PUT /api/v1/admin/users/me/profile
Cookie: packr_session=<session>
Content-Type: application/json

{
  "display_name": "Kristian Mandrup",
  "bio": "Open source developer",
  "website": "https://example.com"
}

Response (200 OK):

{"ok": true}

Notes:

  • avatar_url is set automatically from OAuth provider and cannot be updated directly.

Get Public Profile

GET /api/v1/users/:username/profile

No authentication required. Returns non-sensitive profile fields for all users.

Response (200 OK):

{
  "username": "kristian",
  "display_name": "Kristian Mandrup",
  "bio": "Open source developer",
  "website": "https://example.com",
  "avatar_url": "https://avatars.githubusercontent.com/u/123456"
}

Errors:

  • 404 — User not found

Error Response Format

All errors return JSON:

{
  "error": {
    "code": "ERROR_CODE",
    "message": "Human-readable message",
    "status": 400
  }
}

Error Codes

CodeHTTP StatusDescription
AUTH_REQUIRED401Missing token
AUTH_INVALID401Token invalid or expired
FORBIDDEN403Not authorized for this action
RATE_LIMITED429Rate limit exceeded
LIMIT_EXCEEDED403/413Plan limit or payload size exceeded
NOT_FOUND404Resource not found
VALIDATION_ERROR400Invalid request body or parameters
METHOD_NOT_ALLOWED405HTTP method not allowed for this endpoint
VERSION_EXISTS409Version already published
PROXY_ERROR502Upstream proxy registry error
STORAGE_ERROR502Blob storage (S3/B2) error
GATEWAY_TIMEOUT504Upstream registry timed out
SERVICE_UNAVAILABLE503Service temporarily unavailable
INTERNAL_ERROR500Internal server error

Rate Limits

EndpointLimitWindow
POST /-/v1/login10 req1 min/IP
POST /-/v1/password-reset10 req1 min/IP
POST /-/v1/device/authorize5 req1 min/IP
POST /-/v1/device/token60 req1 min/IP
PUT / (publish)30 req1 min/IP
Admin API60 req1 min/IP
Search/Agent API30 req1 min/IP

See also: docs/SECURITY.md — Security controls, docs/deployment.md — Deployment guide