# 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 ` 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: ```json { "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 ```jsonc { "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 ```jsonc { "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. ### DNS providers — NPM tells you which it can drive ``` GET /api/nginx/certificates/dns-providers ``` Authenticated (`certificates.view`). Returns one entry per provider: ```jsonc { "id": "cloudflare", "name": "Cloudflare", "credentials": "dns_cloudflare_api_token = " // a template, with the field names in it } ``` **86 providers on the 2.15.1 image here**, verified 2026-08-25 against the live instance and against `/app/certbot/dns-plugins.json` inside the container, which is where the endpoint reads from. Two things follow, and they decide how the provider picker should be built: - **The list is per install, not universal.** It is whatever certbot DNS plugins that NPM image carries, so an older container genuinely cannot drive a provider a newer one can. Curating this list ourselves and shipping it as the truth would offer somebody a provider their NPM has never heard of, take their API key, and fail at issue time with an error that does not say why. **Ask the instance.** - **NPM also describes the credential SHAPE.** `credentials` is a template carrying that provider's own field names — one token for Cloudflare, key and secret for others. So the form can be generated rather than hand-written per provider, and it stays right when NPM adds providers we have never heard of. Which leaves a smaller job for any curated list of our own than it first appears: not which providers exist or what fields they need — NPM answers both — but the human half. Which ones we recommend, and how a person actually gets a key out of each one. ### The 404 rule is not universal This endpoint answers **403 `Permission Denied`** unauthenticated, not the 404 documented above. So the "NPM answers 404 when it means 401" quirk is per route rather than a property of the API, and `npm-client.ts` treating 404 as probable-auth stays correct — it is a heuristic that fires on the routes that need it, not a rule the whole API obeys. --- ## 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.