Files
proxy-manager/docs/npm-api.md
T
pastilhasandClaude Fable 5 5914e96cf3 proxy-manager: nginx proxy manager in officer
reads hosts and certificates against a live NPM 2.15.1. one grantable
permission, with /_config owner-only — reading which hosts are healthy
is worth sharing, an NPM admin login is not, and NPM has no read-only
role.

the quirk that shapes the whole plugin: NPM answers an unauthenticated
read with 404, not 401, because its router only registers a resource
once a token is present. so 'not signed in' and 'no such endpoint' are
the same response, and a naive client tells you a feature is missing
when your password is wrong. the client mints a fresh token and retries
once, then reports a persistent 404 as likelyAuth.

certificates sort by expiry rather than name, which is the only ordering
that answers a question.

no create/edit/delete and no cert issuance: NPM's own UI does those and
a wrong proxy host takes a domain off the internet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 08:38:15 +00:00

4.3 KiB

Nginx Proxy Manager's API, as observed

Verified against 2.15.1 on alpha, 2026-08-25. NPM's API is not documented upstream in any form worth relying on, so this is what was learned by probing it — recorded so nobody has to probe it twice.


Authentication

POST /api/tokens          {"identity": "you@example.com", "secret": "…"}
  -> {"token": "eyJ…", "expires": "2026-08-26T10:00:00.000Z"}

Then Authorization: Bearer <token> on everything else.

A bad credential is 400, not 401, with {"error":{"code":400,"message":"Invalid email or password"}}.

An unauthenticated read is 404, not 401.

GET /api/nginx/proxy-hosts
  -> {"error":{"code":404,"message":"Not Found - /nginx/proxy-hosts"}}

This is the single most important thing on this page. NPM only registers a resource route once the request carries a token, so "not signed in" and "no such endpoint" are indistinguishable. Any client that reports the status honestly will tell its user a feature is missing when the password is wrong.

api/npm-client.ts handles it by minting a fresh token and retrying once, then reporting a persistent 404 as likelyAuth.

The unauthenticated corner

GET /api/ needs no token and is the cheapest proof a URL is an NPM at all:

{"status":"OK","setup":true,"version":{"major":2,"minor":15,"revision":1}}

setup: false means NPM has not had its first admin created yet.

Error shape

Always the same, which is a small mercy:

{ "error": { "code": 404, "message": "Not Found - /nginx/proxy-hosts" } }

Resources

path
/nginx/proxy-hosts the main one
/nginx/redirection-hosts 301/302 redirects
/nginx/dead-hosts NPM's name for "404 hosts" — a domain served deliberately as nothing
/nginx/streams raw TCP/UDP forwarding
/nginx/certificates Let's Encrypt and uploaded
/nginx/access-lists basic auth and IP rules
/audit-log who changed what
/settings, /users administration

?expand=

Several resources take an expand list, which is how you avoid N+1 fetching:

/nginx/proxy-hosts?expand=certificate,access_list
/nginx/proxy-hosts/3?expand=certificate,access_list,owner
/nginx/access-lists?expand=items,clients

A proxy host, narrowed to the useful fields

{
  "id": 3,
  "domain_names": ["gitea.officer.dev"],
  "forward_scheme": "http",
  "forward_host": "172.20.0.3",
  "forward_port": 3000,
  "enabled": 1,                 // 0/1, not a boolean
  "ssl_forced": 1,
  "certificate_id": 7,          // 0 means none
  "access_list_id": 0,          // 0 means none
  "meta": {
    "nginx_online": true,       // whether nginx accepted the generated config
    "nginx_err": null
  }
}

meta.nginx_online is the interesting one. A host can be enabled and still be offline because the generated nginx config failed to load — a certificate that vanished, a syntax error from a custom config block. It is the difference between "should be running" and "is running", and it is what the hosts panel shows as a warning triangle.

Booleans are 0/1 integers throughout.

Enabling and disabling

POST /api/nginx/proxy-hosts/:id/enable
POST /api/nginx/proxy-hosts/:id/disable

No body. This is the only mutation this plugin makes, because it is the only one that is reversible in a click.

Certificates

{
  "id": 7,
  "provider": "letsencrypt",    // or "other" for an uploaded one
  "nice_name": "officer.dev",
  "domain_names": ["officer.dev", "*.officer.dev"],
  "expires_on": "2026-11-01T00:00:00.000Z"
}

expires_on is null for some uploaded certificates. The router computes daysUntilExpiry server-side so the browser is not parsing dates and getting the timezone wrong.


Reaching NPM from Officer

On alpha, NPM is the npm container on the nginx docker network at 172.20.0.2, with its admin UI published on 100.64.0.13:81 and 127.0.0.1:81.

Both http://127.0.0.1:81 and http://172.20.0.2:81 were probed and both answer. The container address works whether or not the port is published, because the host is the gateway on every docker bridge — opm's docs/networking.md has the measurements. So the connection URL can be the container address, and the published port on 81 becomes an exposure decision rather than a requirement.