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>
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.