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:
auth | Meaning |
|---|---|
jwt | Signature verified against the current JWT_SECRET. Works everywhere, including the admin API. |
token | Resolved 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 token401— 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 credentials429— 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 tarball401— Missing or invalid token403— Not authorized to publish to this package413— Tarball too large (default: 5 MB, configurable viaMAX_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.
| Format | URL pattern | Example |
|---|---|---|
| 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 window401— Missing or invalid token403— Not the package owner404— 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 fields401— Missing or invalid token403— Not the package owner404— 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.0cannot distinguish a tag from a version. latestcannot be removed, only repointed. Removing it leaves a plainnpm installwith nothing to resolve.
Earlier versions of this document described
GET /:scope/:pkg/-/package/dist-tags. That path was never served, andnpm dist-taganswered 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 bynameorupdated(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.
Cross-Ecosystem Search
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 ofnpm|go|cargo|jvm|maven|pypi|nuget; omitted = all.limit/offset— default20/0;limitcapped 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 token400— 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 provider429— 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 code | Meaning |
|---|---|
authorization_pending | User hasn't completed login yet. Keep polling. |
slow_down | Polling too fast. Increase interval by 5s. |
expired_token | Code expired (15 min). Start a new flow. |
access_denied | User 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"]
}
Capability Search
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 name401— 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:
| Field | Effect |
|---|---|
jti | Adds that token id to the revocation denylist. |
token | The 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). |
id | Deletes 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 ofjti,token, oridsupplied, or the row does not exist401— 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_user401— Not authenticated403— Not the package owner or admin404— 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— Missingbefore, or invalid semver401— Not authenticated403— Not the package owner or admin404— 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 invalidkeep(must be ≥ 0)401— Not authenticated403— Not the package owner or admin404— 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_urlis 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_urlis 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
| Code | HTTP Status | Description |
|---|---|---|
AUTH_REQUIRED | 401 | Missing token |
AUTH_INVALID | 401 | Token invalid or expired |
FORBIDDEN | 403 | Not authorized for this action |
RATE_LIMITED | 429 | Rate limit exceeded |
LIMIT_EXCEEDED | 403/413 | Plan limit or payload size exceeded |
NOT_FOUND | 404 | Resource not found |
VALIDATION_ERROR | 400 | Invalid request body or parameters |
METHOD_NOT_ALLOWED | 405 | HTTP method not allowed for this endpoint |
VERSION_EXISTS | 409 | Version already published |
PROXY_ERROR | 502 | Upstream proxy registry error |
STORAGE_ERROR | 502 | Blob storage (S3/B2) error |
GATEWAY_TIMEOUT | 504 | Upstream registry timed out |
SERVICE_UNAVAILABLE | 503 | Service temporarily unavailable |
INTERNAL_ERROR | 500 | Internal server error |
Rate Limits
| Endpoint | Limit | Window |
|---|---|---|
POST /-/v1/login | 10 req | 1 min/IP |
POST /-/v1/password-reset | 10 req | 1 min/IP |
POST /-/v1/device/authorize | 5 req | 1 min/IP |
POST /-/v1/device/token | 60 req | 1 min/IP |
PUT / (publish) | 30 req | 1 min/IP |
| Admin API | 60 req | 1 min/IP |
| Search/Agent API | 30 req | 1 min/IP |
See also: docs/SECURITY.md — Security controls, docs/deployment.md — Deployment guide