Files
pastilhasandClaude Opus 5 ca77a4507d NPM already answers which DNS providers it can drive
GET /nginx/certificates/dns-providers, authenticated, 86 entries on the 2.15.1
image here. verified against the live instance and against the file it reads
from, /app/certbot/dns-plugins.json in the container.

which settles how the provider picker gets built. the list is per install —
whatever certbot plugins that image carries — so curating one centrally 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 explain itself. ask the instance.

and NPM hands back each provider's credential TEMPLATE, carrying its own field
names. so the form is generated rather than written per provider, and it stays
right for providers we have never heard of. a curated list of ours is left with
the human half only: which we recommend, and how you actually get a key.

also noted: this endpoint answers 403 unauthenticated, not 404. the 404-means-
auth quirk is per route, not a property of the API. the client's heuristic
stays correct; the documentation of it was too broad.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 16:04:10 +00:00

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

DNS providers — NPM tells you which it can drive

GET /api/nginx/certificates/dns-providers

Authenticated (certificates.view). Returns one entry per provider:

{
  "id": "cloudflare",
  "name": "Cloudflare",
  "credentials": "dns_cloudflare_api_token = <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.