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
179 lines
6.2 KiB
Markdown
179 lines
6.2 KiB
Markdown
# 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:
|
|
|
|
```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 = <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.
|