HTTP endpoints
Every configured index route serves the same surface; {route} below is the index's route, for example root/pypi.
peryx resolves a request to the index with the longest matching route prefix. The API explorer breaks
each endpoint down with copyable example requests and responses.
GET /{route}/simple/: project list, JSON or HTML byAccept.GET /{route}/simple/{project}/: project detail, merged across virtual-index layers.GET /{route}/simpleandGET /{route}/simple/{project}:301to the slash-terminated, name-normalized canonical URL (trailing-slash redirects).GET /{route}/{project}/json: legacy PyPI project JSON:info,releases, and latest-releaseurls.GET /{route}/{project}/{version}/json: legacy PyPI release JSON for one version.GET /{route}/files/{sha256}/{filename}: artifact download, cached content-addressed.GET /{route}/files/{sha256}/{filename}.metadata: PEP 658 core-metadata sibling.GET /{route}/files/{sha256}/{filename}.provenance: PEP 740 provenance object (application/vnd.pypi.integrity.v1+json), served for a file uploaded with attestations.POST /{route}/: upload (legacy API, used by twine anduv publish).GET /{route}/+api: index discovery, absolute URLs, capabilities, and redacted client config.GET /{route}/+search: search this index's cached packages.GET /{route}/inspect/{sha256}/{filename}: archive member listing as JSON.GET /{route}/inspect/{sha256}/{filename}/{member}: one archive member's content.PUT /{route}/{project}/[{version}/]yank: yank files (PEP 592); cached files get an override.DELETE /{route}/{project}/[{version}/]yank: un-yank.DELETE /{route}/{project}/[{version}/]: delete uploads (volatile only); hide cached files.PUT /{route}/{project}/[{version}/]restore: restore hidden cached files.PUT /{route}/{project}/{version}/promote?from=...: promote uploaded records from another route's hosted layer.GET /+api: server discovery, global URLs plus every configured index.GET /+search: search cached packages across every configured index.GET /api-docs/openapi.json: the server's OpenAPI description.GET /+health: fixed, redacted process liveness for restart probes.GET /+ready: redacted local-store readiness for load balancers; add?writes=trueto require a writer. See load-balancer probes for response and deployment examples.GET /+status: JSON health, version, counters, index descriptions.GET /+stats: usage counters, drillable to project and file level.GET /metrics: Prometheus text exposition.GET /_/oidc/audience: trusted-publishing audience discovery;404without a publisher.POST /_/oidc/mint-token: exchange a verified CI identity for a short-lived upload token.
The web UI lives outside the index namespace: GET / (dashboard), GET /admin/status (read-only operational status),
GET /browse (package browser), GET /search (package search), GET /stats (usage drill-down), and GET /pkg/* (the
wasm bundle that hydrates the pages).
Content negotiation
Simple-API responses honor the Accept header: application/vnd.pypi.simple.v1+json
(PEP 691) when the client asks for JSON, text/html
(PEP 503) otherwise. Responses carry Vary: Accept and advertise
meta.api-version 1.4. peryx preserves upstream Simple API fields it understands, including versions, size,
upload-time, project-status, provenance, gpg-sig, and both core-metadata and dist-info-metadata. The
gpg-sig marker is dropped for a file peryx content-addresses onto its own route, since no .asc is served there; see
the gpg-sig marker.
Legacy PyPI JSON API responses use application/json. Peryx builds /pypi/<project>/json-style responses from the
resolved Simple detail page for the requested index route, so releases, urls, hashes, yanked markers, upload time,
size, and requires_python match the Simple API. Simple pages do not carry PyPI's upload-form metadata, vulnerability
database, ownership data, download counts, last serial values, or MD5/BLAKE2 hashes when the upstream did not advertise
them; those fields are null, empty, 0, or -1.
Index policy
Policy rules configured under [index.policy] run before Simple API bytes leave the server. Project-list responses omit
blocked projects. Project-detail responses omit blocked files and remove their versions from PEP 691 versions; when a
project-level rule blocks the whole page, the response is 403 with a JSON policy denial. Search results use the same
effective policy before packages enter the derived search index.
Upload and direct file-download denials use the same JSON shape:
{
"action": "upload",
"project": "flask",
"filename": "flask-1.0-py3-none-any.whl",
"version": "1.0",
"rule": "max-file-size",
"field": "size",
"reason": "file size 2048 exceeds limit 1024"
}
action is one of upload, mirror, or serve. rule names the policy key that denied the artifact or project, and
field names the matched value.
Discovery
GET /+api returns a compact JSON document for the server and every configured index. GET /{route}/+api returns the
same shape for one index. Peryx builds these documents from request headers and runtime index configuration; it does not
scan package pages or storage.
When the request carries an origin (Host, or X-Forwarded-Host plus X-Forwarded-Proto from a peer listed in
[rate_limit].trusted_proxies), URL fields are absolute and client_configuration includes copyable pip.conf,
uv.toml, and .pypirc text. Forwarding headers from any other peer are ignored. The .pypirc snippet uses
__token__ as the username and <upload-token> as the password, and Peryx never returns the configured upload token.
Read-only indexes omit upload URLs and .pypirc.
Capability flags describe the current route only. uploads, yanking, and volatile_deletes follow the configured
hosted upload target; Simple HTML/JSON, PEP 658 metadata siblings, project status, provenance, and legacy JSON are true
for all indexes.
Authentication
POST, PUT, and DELETE accept a configured long-lived Basic credential. A token from the OIDC exchange instead uses
the exact username __token__ with the minted token as its password, or the raw Bearer scheme. Its grant includes the
public repository route and project; a token for a virtual route cannot write through a sibling or its hosted layer's
direct route. Promotion authenticates against the target route. Responses:
200: accepted; removal responses state how many files changed.400: malformed upload, bad promotion query, or unsafe path segment.401: missing or wrong token.403: uploads disabled, target project status rejects writes, index policy denies the request, or the index is not volatile.404: unknown route, project, or nothing matched.405: the route's index does not accept writes.409: promotion target already has the filename with different bytes.429: a route-class limit rejected the request, or a configured upstream concurrency cap could not free a slot within the wait window; retry after theRetry-Afterseconds.
Webhooks
Configured webhooks run after a write commits. Peryx enqueues one delivery per matching [[index.webhook]] target, then
sends the JSON payload from a background task. Duplicate uploads with the same bytes and mutations that affect zero
files do not enqueue webhook deliveries.
Events emitted by the write endpoints are upload, yank, unyank, delete, and restore. Payloads contain event,
created_at, index, route, local_index, project, count, and, when present, version, file, actor, and
request_id. Upload payloads include file.filename and file.sha256. Payloads and delivery errors exclude
Authorization, upload tokens, upstream credentials, webhook secrets, URL query strings, and response bodies.
Each request carries these headers:
| Header | Meaning |
|---|---|
X-Peryx-Event | Event name, such as upload |
X-Peryx-Delivery | Delivery ID, stable across retries |
X-Peryx-Timestamp | Unix timestamp used for the signature |
X-Peryx-Signature | sha256=<hex> HMAC-SHA256 signature |
Content-Type | application/json |
The signature input is {timestamp}.{delivery}.{body}, where body is the exact request body bytes. Consumers should
compare the HMAC with the configured target secret and reject timestamps outside their replay window.
Uploads accept wheels, .tar.gz sdists, and .zip sdists (PEP 527). The server
validates the filename, form name and version, filetype, archive contents, and
core metadata before the artifact becomes
visible. Wheel validation requires normalized .dist-info paths, required METADATA/WHEEL/RECORD files, WHEEL
tag/build consistency, RECORD hashes, and matching RECORD sizes when present. Both sdist forms meet the same
PEP 625 strictness: a name-version filename split at the last -, one safe
{name}-{version}/ top-level directory, pyproject.toml, and a PKG-INFO whose Metadata-Version is at least 2.2;
unsafe archive members and Metadata 2.4+ missing License-File entries are rejected. A .egg reports a legacy-egg
error and any other extension, such as .tar.bz2, is rejected as unsupported. Wheel uploads serve METADATA as the PEP
658/714 .metadata sibling. Sdist uploads serve the verified PKG-INFO the same way.
Archive inspection is broader than uploads. It can list and preview cached wheels, zips, zipped eggs, .tar, .tar.gz,
and .tgz archives, including supported archives nested inside them. Other legacy compressed tar formats stay
download-only until peryx adds decoders for them. Mirrored eggs remain downloadable when upstream lists them with a
sha256 hash, but they do not get PEP 658 metadata.
Rate limits
When [rate_limit] enabled = true and a client exceeds a configured route-class window, peryx returns
429 Too Many Requests before the handler reads multipart bodies, cache state, or upstreams. The response includes
Retry-After in seconds. A cached index leaves upstream fetches uncapped by default; when you set
upstream_concurrency and the cap is saturated, requests wait for a free slot instead of failing, and only a wait
longer than 30 seconds returns the same 429 with Retry-After.
Peryx writes a security log for each denial with event = "rate_limit", the denied class or index, and the retry delay.
It never logs credentials. Prometheus includes allowed and denied HTTP request counters by class plus process-wide
upstream concurrency totals. HTTP request counters stay at zero while the request limiter is disabled.
Status and usage
GET /+status returns version, serial, request counters, configured index descriptions, cached index status, and
redacted token metadata. It includes sanitized upstream URLs with user info, query strings, and fragments removed. It
does not include upload-token values, upstream usernames, passwords, bearer tokens, URL query secrets, or URL fragments.
Cached index entries also include upstream.offline, which is true when that cached index is serving only cached
data.
Add ?details=admin for the read-only admin status page. That shape also includes observed project counts, uploaded
file counts, and capped recent uploads. The summary scans metadata keys once and does not fetch upstreams or read cached
artifact bytes.
GET /+stats returns JSON counters aggregated off the request path, at three depths:
- No parameters: totals per index route.
?index={route}: one index's totals plus a counter set per project.?index={route}&project={name}: one project's totals plus downloads, metadata hits, and bytes per file.
The counters are pages, downloads, metadata, uploads, bytes, refreshes (upstream revalidations), changed
(revalidations that found new upstream content), stale_served (pages served from cache with upstream down),
upstream_errors (failures with nothing cached), and rejected (downloads whose bytes failed digest verification and
were not cached). Counters reset on restart; scrape /metrics for durable time series.
Metrics
GET /metrics exposes Prometheus counters and gauges:
peryx_requests_total: HTTP requests served.peryx_rate_limit_allowed_total{class="<class>"}: HTTP requests the local rate limiter allowed.peryx_rate_limit_denied_total{class="<class>"}: HTTP requests the local rate limiter denied.peryx_upstream_rate_limit_denied_total: cached-index concurrency cap denials across the process.peryx_upstream_inflight_fetches: current upstream fetches holding a concurrency slot across the process.
Serving counters carry only {ecosystem="<ecosystem>",role="<role>"}. Values from repositories with the same ecosystem
and role are summed before rendering. Each family is scoped to the role that reports it:
- Base (every role):
peryx_pages_served_total,peryx_artifacts_served_total,peryx_artifacts_served_bytes_total,peryx_artifacts_rejected_total. - Caching indexes only:
peryx_upstream_refreshes_total,peryx_upstream_pages_changed_total,peryx_stale_pages_served_total,peryx_upstream_errors_total. - Hosted indexes only:
peryx_artifacts_uploaded_total. - Ecosystem families:
peryx_metadata_served_totalis PyPI's PEP 658/714.metadatasibling counter. A rising value proves clients resolve through the metadata fast path instead of downloading artifacts.peryx_provenance_served_totalis the PEP 740 provenance-object counter.
The label vocabulary is fixed by peryx: five request classes, two ecosystems, and three roles. Repository names, package
names, user or actor identifiers, request paths, raw errors, credentials, tokens, and URLs never become metric names or
labels. Use /+stats when repository, project, or file detail is required. Keep /metrics access-controlled as part of
the operational surface even though these fields are absent.
This contract follows Prometheus label guidance and OpenTelemetry's data-minimization guidance: omit sensitive dimensions when aggregate data answers the operational question.
Metrics compatibility
The bounded series replace the per-repository series. Existing dashboards and alerts must use these names:
| Removed series | Replacement |
|---|---|
peryx_index_pages_total | peryx_pages_served_total |
peryx_index_downloads_total | peryx_artifacts_served_total |
peryx_index_download_bytes_total | peryx_artifacts_served_bytes_total |
peryx_index_rejected_total | peryx_artifacts_rejected_total |
peryx_index_refreshes_total | peryx_upstream_refreshes_total |
peryx_index_pages_changed_total | peryx_upstream_pages_changed_total |
peryx_index_stale_served_total | peryx_stale_pages_served_total |
peryx_index_upstream_errors_total | peryx_upstream_errors_total |
peryx_index_uploads_total | peryx_artifacts_uploaded_total |
peryx_index_metadata_total | peryx_metadata_served_total |
The index label was also removed from peryx_upstream_rate_limit_denied_total and peryx_upstream_inflight_fetches.
Replace an instance-wide download query such as sum by (ecosystem, role) (rate(peryx_index_downloads_total[5m])) with
rate(peryx_artifacts_served_total[5m]). Replace sum(rate(peryx_upstream_rate_limit_denied_total{index=~".+"}[5m]))
with rate(peryx_upstream_rate_limit_denied_total[5m]). There is no per-repository Prometheus replacement because that
dimension was the source of unbounded cardinality and secret exposure; use /+stats for current per-repository totals.