Files
proxy-manager/docs/npm-api.md
T
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

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.