The dashboard is a client of this API and uses nothing private, so anything the UI can do, a script can do.
Everything lives under /api on dashboard_host. All responses are JSON,
including 404s for routes that don't exist. Errors are { "error": "..." }
with a 4xx or 5xx status. Request bodies are limited to 1 MB; anything larger
is 413, refused before the signature is checked.
Every route except /api/instance and /api/sites requires NIP-98 auth. The
tunnel's own routes are signed by the tunnel's key rather than a
user's; the rest of this section applies to them as it is. Sign a
kind 27235 event and send it base64-encoded:
Authorization: Nostr <base64(event)>
The event needs:
| Tag | Value |
|---|---|
u |
The full public URL, e.g. https://panel.example.com/api/hosts |
method |
The HTTP method, uppercase |
payload |
sha256 of the request body — required only when there is one |
created_at must be within 60 seconds of the server's clock. Each event is for
one request; sign a fresh one every time.
The u tag is checked against dashboard_host and the request path, not
against the URL the process sees, so a reverse proxy rewriting the scheme or
port is fine. Host and path must match exactly.
Every response allows any origin (Access-Control-Allow-Origin: *), and
preflights are answered. The signature, not the origin, is what grants access,
so a dashboard served from somewhere else — the published
nsite, say — works against any
server, as long as its u tags name that server's dashboard_host.
A valid signature from a pubkey that isn't in the users table gets 403, not
401 — npanel is permissioned, and signing proves who you are, not that you're
allowed in.
Roles:
- admin — everything
- staff — only hosts explicitly granted to them, plus any host they created
Routes marked admin below return 403 for staff.
Certificate authorization for the reverse proxy is not part of this API. It
answers on its own port, tls_check_port, because the proxy asks with no
hostname npanel could route on — see
deployment.
GET http://127.0.0.1:3001/tls-check?domain=<hostname>
200 with ok when the domain is dashboard_host or an enabled host, 404
otherwise, and 429 with a Retry-After once tls_check_limit_per_host or
tls_check_limit_total is spent. Refusals aren't metered, and dashboard_host
is exempt. Nothing else is served on that port.
With https_port set npanel gets certificates itself and that port isn't
bound; how they stand is in /api/status and each host's
certificates.
Unauthenticated, for the logged-out homepage, which introduces npanel to whoever landed on it and names the person running this copy.
{ "contact_npub": "npub1...", "dashboard_host": "panel.example.com", "version": "0.2.0" }contact_npub is null when no admin has set
contact_npub. Nothing here is private: the
npub is published to be shown, and the hostname is the one the caller used to
reach this route. version is the server's, so a dashboard served from
somewhere else can tell it is newer than the API it is talking to; servers
before 0.2.0 leave it out.
Unauthenticated. The sites this gateway will admit to serving, for the logged-out homepage.
{
"sites": [
{
"hostname": "example.org",
"title": "The Docs",
"description": "Everything about the thing.",
"image": "https://example.org/cover.png",
"icon": "https://example.org/apple-touch-icon.png"
}
]
}sites is empty unless an admin turned
public_site_list on, and never includes a
host that is disabled or marked hidden. Nothing here says which nsite a site
points at, or who runs it.
title, description, and image are the site's own Open Graph, Twitter, or
<title> metadata, read from the copy of its index.html npanel already
serves — no crawling, and no request leaves the box. Each is null when the
page doesn't say: a site with nothing deployed yet is still listed, as a bare
hostname. image is always an absolute https URL, resolved against the site's
own hostname, since a relative og:image belongs to the site npanel serves.
icon is the site's own icon, picked the way a home screen would: an
apple-touch-icon first, then an icon from the web app manifest the page
links (any but a bare monochrome silhouette), then a <link rel="icon">,
the largest of each kind first. Files at the conventional names —
/apple-touch-icon.png, /icon.svg, /favicon.ico and the like — count even
when nothing links them. An icon on the site's own domain
counts only if the build really contains the file, so a stale link is skipped
rather than shown broken. null when there is none.
{ "pubkey": "<hex>", "npub": "npub1...", "role": "admin" }The records that point a hostname at this server, which the dashboard shows while staff are adding one. Available to any authenticated user.
{
"records": [
{ "type": "A", "value": "203.0.113.10" },
{ "type": "AAAA", "value": "2001:db8::1" }
],
"source": "dashboard_host",
"dashboard_host": "panel.example.com"
}source is settings when an admin set dns_targets, and dashboard_host
when the answer was detected by resolving dashboard_host — see
configuration. records is empty when neither
produced anything; type is A, AAAA, or CNAME.
A host is addressed by its hostname, which is unique — /api/hosts/example.org.
The database's row id is never part of the API.
A host object:
{
"hostname": "example.org",
"pubkey": "<hex>",
"kind": 35128,
"identifier": "docs",
"address": "35128:<hex>:docs",
"naddr": "naddr1...",
"enabled": true,
"hidden": false,
"redirect_www": true,
"spa": false,
"csp": true,
"csp_policy": "default-src 'none'; script-src 'self'; …",
"csp_source": "gateway",
"seo": true,
"merge_deployments": false,
"analytics": false,
"analytics_script": true,
"link_previews": false,
"errors": false,
"onion": "abc…xyz.onion",
"certificates": [
{ "hostname": "example.org", "expires_at": 1761000000, "error": null, "failures": 0, "attempted_at": 1753000000 }
],
"pinned_event_id": null,
"created_by": "<hex>",
"created_at": 1754000000,
"updated_at": 1754000000,
"active": { "...": "the version the host's build serves, or null" },
"latest": { "...": "the newest known version, or null" }
}onion is read-only, and null unless
tor_enabled is on. npanel assigns it, not
staff: enabling Tor gives every enabled host an address, a host added afterwards
gets one a moment after it is created, and deleting a host takes its address
with it for good. It is stable otherwise — a restart of npanel or of Tor
republishes the same address. There is no field to set it and no per-host way to
opt out.
certificates is read-only, and null unless npanel gets certificates itself
(https_port). It lists the host's own name and, if it redirects it, its www;
a disabled host has none. expires_at is when the certificate being served
expires, null while there is none. error is why the last attempt failed,
in the certificate authority's words, and failures how many have failed in a
row — null and 0 once one succeeds. A name nothing has been tried for yet
has all four empty.
A version object:
{
"event_id": "<hex>",
"created_at": 1754000000,
"status": "ready",
"files_total": 42,
"files_ready": 42,
"error": null,
"fetched_at": 1754000001,
"ready_at": 1754000009
}status is one of pending, fetching_blobs, ready, failed. Only ready
versions are ever served, and only once the host has been built from one, so
active and latest differ while a deploy or the build after it is in flight,
or after either fails. A failed build says why in the version's deploy log.
{ "hosts": [...], "other_sites": [{ "hostname": "example.org" }] }hosts is ordered by hostname; staff see only the hosts granted to them. Each
host carries a preview — title, description, image and icon, exactly
as /api/sites describes them — whether or not it is listed.
other_sites names what else the gateway serves, on exactly the terms
/api/sites does — so it is empty when public_site_list is off, and always
empty for an admin, whose hosts already holds everything.
{ "hostname": "example.org", "address": "npub1...", "hidden": false, "redirect_www": true, "spa": false, "csp": true, "seo": true, "merge_deployments": false, "analytics": false }address accepts an npub, nprofile, naddr, an a tag
(35128:<pubkey>:<identifier>), a NIP-5A label, or a URL containing one of
those. It's stored decomposed into pubkey, kind, and identifier.
201 with { "host": {...} }. 409 if the hostname is already configured or
is dashboard_host. 400 if it doesn't sit under one of
allowed_host_suffixes, when that setting is non-empty. A staff user who
creates a host is granted it automatically, so they aren't locked out of their
own work.
{ "host": {...} }, or 404 / 403.
Any subset of:
{
"hostname": "new.example.org",
"address": "naddr1...",
"enabled": false,
"hidden": true,
"redirect_www": true,
"spa": true,
"csp": false,
"seo": true,
"merge_deployments": true,
"analytics": true,
"analytics_script": false,
"link_previews": true,
"errors": true,
"pinned_event_id": "<event id>"
}Set pinned_event_id to an event id to freeze the host on that version, or
null to follow the newest ready one. Pinning a version that belongs to a
different site is 400; pinning one that isn't fully downloaded is 409.
Changing address clears any pin, since it belonged to the old site.
hidden keeps the site out of /api/sites and out of other users'
other_sites. It changes nothing about serving it: an unlisted site answers
requests exactly as before.
redirect_www sends www.<hostname>/<path> to <hostname>/<path> with a
301, and defaults to on. It only acts when the hostname is a root domain
(example.org, example.co.uk — decided by the Public Suffix List), and never
when www.<hostname> is configured as a host of its own. Setting it on a
subdomain is accepted and does nothing, so a later rename doesn't silently
leave www unserved.
spa decides what a path the site has no file for returns, and defaults to
off. Off, it's a 404, carrying the site's own /404.html if it published
one. On, a document request — a path with no extension, one ending .html,
or one ending in someone's address like /@bob@example.com — gets the site's
shell (/404.html if present, else /index.html)
with a 200, so a client-side router can decide what the page is. Requests for
anything else still 404 either way: a script or stylesheet that answers 200
with a page of HTML doesn't render the site, it fails somewhere harder to read.
Paths under /.well-known/ are served only if the site has that exact file;
anything else there is a plain 404 in both modes.
csp decides whether this site is served under a Content-Security-Policy at
all, and defaults to true — including for hosts created before the flag
existed. Which policy is the site's own decision, and the read-only
csp_source says which of the two it got:
"site" — the site declares a policy in a <meta http-equiv> tag of its
/index.html, and npanel sends that one, verbatim, on every response for the
host. Multiple tags become the comma-separated list the header defines, which is
how a browser reads several meta policies anyway. A -Report-Only tag is not a
declaration and is skipped, and so is a policy carrying a newline or one longer
than 8 KB — those fall back to the gateway's.
"gateway" — the site declares nothing, so it gets npanel's own:
default-src 'none'; script-src 'self' 'wasm-unsafe-eval';
style-src 'self' 'unsafe-inline'; frame-src 'self' https:;
font-src 'self' blob: data:; base-uri 'self'; manifest-src 'self';
connect-src 'self' blob: https: wss:; img-src 'self' data: blob: https:;
media-src 'self' https: blob: data:
That is the policy mkstack ships as a <meta> tag,
which is what most sites reaching an npanel gateway are built from, with three
allowances taken from the policy Isolated Web
Apps are held to: WebAssembly
('wasm-unsafe-eval'), fonts from blob: and data: — bundlers inline small
ones — and media from blob: and data:. None of them lets a site run script
from anywhere but its own files. Sent as
a header it also covers what a meta tag can't: responses that aren't HTML, and
the fact that a document's own <meta> is only reached after the parser has
already run whatever came before it.
csp_policy is the policy actually in force, whichever it came from, and null
when the flag is off. Both fields are computed from the version being served, so
they change when the site redeploys.
Because a declared policy replaces rather than joins the gateway's, a site can
widen its own: publishing script-src 'unsafe-inline' in /index.html is
enough. That is the site author's call to make about their own hostname — it is
their code running either way — and it reaches no other host. What it does mean
is that csp: true is a promise that a policy is sent, not that npanel's is.
Turning the flag off sends no policy at all, meta tag or not. It is there for
the site that needs one the gateway's forbids — script-src 'self' blocks
inline <script> blocks and CDN scripts, and the script silently doesn't run —
and that can't be rebuilt or given a policy of its own. It is scoped to the one
site: there is no instance-wide switch, so turning it off for the site that
needs it can't accidentally disarm the rest.
seo decides whether npanel does three things for the site's standing in
search engines, and defaults to true — including for hosts created before the
flag existed. A /sitemap.xml is generated from the version's file list, and a
/robots.txt naming it, each only when the site published no file at that
exact path. And every page response carries a Link: rel="canonical" header
naming its one URL, so /foo, /foo/, /foo.html and /foo/index.html are
indexed as one page rather than four competing copies. The URL named is the
clean one: .html comes off and index.html collapses into its directory,
which is both what site generators link to and always servable here, since an
extensionless path resolves to its .html sibling. .htm is left alone, as
it has no such step. Turn the flag off for a site that manages canonical URLs
itself — one that declares <link rel="canonical"> tags of its own would be
contradicted by the header.
robots.txt is the one file npanel will add to rather than only stand in for.
A site that published one naming no sitemap gets npanel's Sitemap: line
appended to it; everything the site wrote is copied through verbatim, never
reflowed or reordered. Sitemap is a non-group directive, so the addition
grants no crawler anything and cannot change what an Allow or Disallow
above it means. The file is left exactly as published when it already names a
sitemap — including one of its own, like a sitemap-index.xml — when any
group carries a blanket Disallow: /, or when it is larger than 64 KB. The
appended file is made when the host is built and stored like any other, so it
is served compressed, with an ETag of its own hash and range support.
merge_deployments stacks the site's earlier deploys underneath the one being
served, oldest first, and defaults to false. A path is then answered from the
newest deploy that has it, so the current deploy wins everywhere it published a
file and a path it dropped goes on being served from the deploy that last did.
Only fully-downloaded versions take part, and never one newer than the version
being served — a pinned host merges up to its pin. Turn it on for a build that
loads modules by name at runtime, like Vite: a page a visitor already has open
goes on requesting the chunk filenames of the deploy it was loaded from, and
those are exactly the files the next deploy removes. The generated
sitemap.xml is unaffected — it lists the served version's own pages, since
keeping a withdrawn URL answering is not a reason to invite a crawler to index
it.
analytics counts visits to the site, and defaults to false. With it on,
every HTML page the site serves loads npanel's tracker — Plausible's own
@plausible-analytics/tracker, served from the site at /_npanel/script.js
(loaded as /_npanel/script.js?v=<hash>, so a new tracker is picked up at
once) and reporting to /_npanel/api/event on the same origin — so nothing needs
configuring and a visit over the site's onion address counts too. The pages are
rewritten when the host is built, not per request: the one tag, an async
module that nothing on the page waits for, is added at the end of <head>,
every other byte is left as published, and the result is
stored and compressed like any other file. A <meta> Content Security Policy
in a page is widened just enough for the tracker — 'self' added to whichever
directives govern loading a script and fetching — and the header follows
the page. A site that publishes files under /_npanel/ has
/_npanel/script.js shadowed while this is on.
analytics_script says how a counting site is counted, and defaults to
true: the tracker, as above. Set to false, the pages get no tracker and are
served exactly as published, and npanel counts each page a browser opens as it
serves it — a GET for an HTML page answered 200 or 304, carrying
Sec-Fetch-Mode: navigate and Sec-Fetch-Dest: document and no
Sec-Purpose or Purpose. Events sent to /_npanel/api/event are dropped
while it is off, so a site is never counted both ways. Moves within a
single-page app, time on page and scroll depth are only known to a script, so
they aren't counted this way. It has no effect while analytics is off. See
analytics.
errors reports what the site's pages throw, and defaults to false. With it
on, every HTML page loads npanel's error reporter, /_npanel/errors.js, as the
first thing in its <head> — a small classic script, so it is listening before
anything the site wrote runs — and it sends uncaught errors and unhandled
rejections, with the clicks, navigations, requests and console output before
them, to /_npanel/api/report on the same origin as Sentry envelopes. A
<meta> policy is widened for it as for the tracker. Issues are read with the
errors endpoints. See errors.
link_previews runs the site's preview script,
/.well-known/npanel/preview.js, for crawlers, and defaults to false. It is
for sites whose pages a crawler can't read by itself, like a Nostr client that
shows /<npub> or /<nevent> from relays in the browser. A GET from a
User-Agent matching
link_preview_crawler_pattern,
for a page the script's routes name (every page, if it names none), where the
site's answer is an HTML page with a 200, gets that page with what the script
made of it: its title, description, image and the rest in its head — as Open
Graph, Twitter and schema.org JSON-LD — and its article at the top of its body.
Everyone else, and a page the script has nothing for, gets the page as
published; responses on those pages carry Vary: User-Agent. A site with no
script gets nothing from it. It is off by default because it changes what a page says depending on who
asks, and runs code the site wrote. See
link previews.
hostname is validated exactly as on create: 409 if it's already configured
or is dashboard_host, 400 if it falls outside allowed_host_suffixes. A
rename also moves the host: every subsequent request must use the new name.
A change to hostname, address, pinned_event_id, seo,
merge_deployments, analytics, analytics_script or errors rebuilds the host in the background, since each changes
the files it serves. The response doesn't wait: the previous build goes on
serving until the new one is complete, which is usually a moment.
{ "ok": true }. Removes the mapping. The site's stored versions and blobs go
at the next daily prune, unless another host points at the same site.
{
"active_event_id": "<hex or null>",
"pinned_event_id": "<hex or null>",
"versions": [ ... ],
"storage": { "versions": 12, "blobs": 340, "bytes": 52428800 }
}Newest first. limit is clamped to 1–200. Each version carries a size: the
bytes of its files stored so far, each counted once. storage is what the
site keeps on disk: how many versions, and every file they and this host's
builds use, compressed copies included, each counted once however many
versions share it.
The deploy trace for one version — what the dashboard shows when a deploy fails.
{
"version": { "...": "version object" },
"entries": [
{
"id": 12,
"level": "warn",
"msg": "blob_download_failed",
"fields": { "sha256": "...", "server": "...", "reason": "http", "status": 404 },
"created_at": 1754000005
}
]
}Oldest first, capped at the 500 most recent entries per version.
Files in a version, defaulting to the active one (or the newest if none is ready).
Without event_id this is the host's build — what the site actually serves.
On a host with merge_deployments it carries the paths held over from earlier
deploys, each marked inherited, and it carries the files npanel generated or
rewrote, marked by source. Naming a version with event_id lists that
version's own manifest and nothing else.
{
"event_id": "<hex>",
"files": [
{
"path": "/index.html",
"sha256": "<hex>",
"size": 1024,
"stored": true,
"inherited": false,
"source": "site"
}
]
}stored is false for a blob that hasn't been downloaded yet — which is how you
spot exactly what a stalled deploy is missing. inherited is true for a path
the named version doesn't publish and a merged earlier one does. source is
site for a file as published, generated for one npanel made outright — a
sitemap, the analytics tracker — and modified for one it rewrote, like a
robots.txt with a line appended or a page loading the tracker.
Fetch the newest manifest from relays now. Returns 202 immediately;
relay round trips take seconds, so poll the host or its versions for the result.
{ "grants": [{ "user_pubkey": "<hex>", "created_at": 1754000000 }] }
{ "pubkeys": ["<hex>", "<hex>"] }Replaces the grant list wholesale. Every pubkey must be hex and must already be
an npanel user. Returns { "grants": [...] }.
The NIP-05 names this host answers for, ordered by name.
{
"names": [
{
"name": "bob",
"pubkey": "<hex>",
"relays": ["wss://relay.example.com"],
"created_at": 1754000000,
"updated_at": 1754000000
}
]
}Not admin-only. A name is a setting of the site, so anyone with a grant on the site manages its names.
{ "pubkey": "npub1… | nprofile1… | <hex>", "relays": ["wss://relay.example.com"] }Adds the name, or replaces whatever was stored under it — 201 for the first,
200 for a replacement. Either way the response is the whole list, in the shape
GET returns.
name is the local part, lowercased: the bob in bob@example.com. NIP-05
restricts it to a-z0-9-_., and anything else is a 400 rather than something
escaped, since a name outside that set is one no client will ever look up. _
is legal and is the identifier clients render as the bare domain.
relays is optional, at most ten, and each must be wss://. They
are normalized and deduplicated, so the same relay written two ways is stored
once. The pubkey is stored and served as hex whatever form it arrives in — the
NIP forbids npub in the document itself.
Removes one name; 404 if the host never had it. Returns the remaining list.
Deleting the host deletes its names with it.
The variables this host's pages read as window.ENV, ordered by name. See
the architecture for how they reach a
page.
{
"variables": [
{
"name": "API_URL",
"value": "https://api.example.com",
"created_at": 1754000000,
"updated_at": 1754000000
}
]
}Not admin-only, for the same reason NIP-05 names aren't.
{ "value": "https://api.example.com" }Sets the variable — 201 for a new one, 200 for a new value — and returns
the whole list, in the shape GET returns. The host is rebuilt, and pages load
the new values once the build is live, usually within a second.
name is kept as written, case and all, and must be letters, digits and _,
not starting with a digit — the rule shells hold environment variables to — and
at most 128 characters. value is any string up to 4,096 characters, stored
verbatim; an empty one is a value like any other. A host may have at most 100
variables. Anything outside those is a 400.
Values are served to every visitor of the site. Don't store secrets in them.
Removes one variable; 404 if the host never had it. Returns the remaining
list. Removing the last one takes /_npanel/env.js out of the build, and the
pages go back to exactly what the site published. Deleting the host deletes its
variables with it.
A site's analytics, for anyone who may manage the site. These are the three
endpoints Plausible's own dashboard reads — POST /api/stats/:domain/query,
current-visitors and suggestions — under the host instead of a Plausible
domain, with the same bodies in and out, so anything written against
Plausible's dashboard API reads npanel's. Site and user management have no
counterpart: the grant npanel already checks is the whole of access control.
They answer whether or not the site is counting visits right now, since the
numbers it collected while it was are still there. While the analytics thread
is starting or restarting, every one of them is a 503.
Plausible's dashboard query. The body:
{
"date_range": "28d",
"relative_date": "2026-09-28",
"metrics": ["visitors", "visits", "pageviews", "views_per_visit", "bounce_rate", "visit_duration"],
"dimensions": [],
"filters": [["is", "visit:source", ["Google"]]],
"order_by": [["visitors", "desc"]],
"pagination": { "limit": 9, "offset": 0 },
"include": {
"time_labels": false,
"partial_time_labels": false,
"compare": "previous_period",
"compare_match_day_of_week": true,
"empty_metrics": false,
"present_index": false
}
}date_rangeisrealtime(the last five minutes),realtime_30m,day,24h,7d,28d,30d,91d,month,6mo,12mo,year,all, or["YYYY-MM-DD", "YYYY-MM-DD"].Ndis the N full days beforerelative_date, andNmothe N full months before its month, as in Plausible;month,yearanddayin progress end now. Days are days inanalytics_timezone.metrics:visitors,visits,pageviews,views_per_visit,bounce_rate,visit_duration,time_on_pageandscroll_depth(with a page filter or dimension),percentage, andexit_rate(with thevisit:exit_pagedimension).dimensions:time:minute|hour|day|week|month,event:page, andvisit:source,referrer,channel,utm_medium,utm_source,utm_campaign,utm_content,utm_term,entry_page,exit_page,country,country_name,region,region_name,city,city_name,device,browser,browser_version,os,os_version.filters:[operator, dimension, [values], {"case_sensitive": false}?], withis,is_not,containsandcontains_not, onevent:pageor anyvisit:dimension.include.compare:previous_period,year_over_year, or a pair of dates.
The response is Plausible's: results as {dimensions, metrics} rows in the
order asked for, comparison on each row or — for a time series —
comparison_results beside them, meta with the time labels and their
indices, the present and partial buckets and the empty metrics that were asked
for, and the query echoed with its resolved date ranges.
What differs is only what npanel doesn't record: there are no goals, custom
properties, revenue, imports or segments, and a query asking for them is a
400. Regions and cities are identified by name — DE-Bavaria,
DE-Bavaria-Munich — rather than by ISO code and GeoNames id, because DB-IP's
free database carries neither.
A query Plausible would refuse is a 400 with {"error": "…"} saying why.
The number of visitors seen in the last five minutes, as a bare JSON number.
Values the dashboard's filter box can offer for :filter — page,
entry_page, exit_page, source, channel, referrer, utm_*,
country, region, city, screen, browser, browser_version, os,
os_version — most visited first, at most 25. The query string is Plausible's
older form: period, date, from and to for the range, filters as a
JSON list of filters as above, and q for what has been typed.
[{ "value": "DE", "label": "Germany" }, { "value": "US", "label": "United States" }]The last 24 hours of every site the caller may manage, for the dashboard's list of sites: unique visitors, their percent change against the 24 hours before (100 when there were none), and visitors in each hour, oldest first — 25 buckets, the first and last partial.
{ "sparklines": { "docs.example.com": { "visitors": 42, "change": -8, "hours": [0, 3, 1, "…"] } } }A site's errors, grouped into issues, for anyone who may manage the site.
Like analytics, they answer whether or not the site is reporting right now,
and are a 503 while the errors thread is starting or restarting.
A page of 50 issues in one state — unresolved (the default), resolved or
ignored — the most recently seen first, each with its reports in each of
the last 24 hours, oldest first. counts is how many the site has in each
state, and next the cursor for the page after, or null on the last.
{
"issues": [{
"id": 7,
"status": "unresolved",
"title": "TypeError: Cannot read properties of undefined (reading 'name')",
"culprit": "render (src/App.tsx)",
"level": "error",
"first_seen": 1759000000,
"last_seen": 1759700000,
"events": 41,
"visitors": 12,
"first_release": "<event id>",
"last_release": "<event id>",
"hours": [0, 0, 3, "…"]
}],
"counts": { "unresolved": 1, "resolved": 4, "ignored": 0 },
"next": null
}events counts every report kept — not those turned away once the site had
sent its 60 a minute — and visitors the visitors it reached, each once a
day (see privacy). A release is the event id of the
version the report came from.
{"issue": …}: one issue as above, with hours covering the last 14 days,
and latest_event, the id of its newest kept event.
{"status": "resolved"}, "ignored" or "unresolved". Answers as GET. A
resolved issue stays resolved when it happens again; its counts go on
counting. Audited as issue.update, with the target hostname#id.
A page of 50 of the issue's kept events, newest first, and next, the
before for the page after. An issue keeps its first event and its latest
100; its counts are of every one.
{ "events": [{ "id": 90, "event_id": "<32 hex>", "received_at": 1759700000, "release": "<event id>", "url": "https://example.org/", "browser": "Firefox", "os": "GNU/Linux" }], "next": null }One event as above, with report: its exceptions (innermost cause first),
each frame's place in the original source where the site's source map gives
one (mapped) and the lines around it (context), its breadcrumbs, page,
release, tags and device. older and newer are the ids either side, or
null.
For every site the caller may manage, by hostname: how many issues are unresolved, and how many reports came in over the last 24 hours.
{ "example.org": { "unresolved": 3, "events_24h": 17 } }{
"users": [
{
"pubkey": "<hex>",
"npub": "npub1...",
"role": "staff",
"added_by": "<hex>",
"created_at": 1754000000,
"hostnames": ["example.org", "docs.example.org"]
}
]
}{ "pubkey": "npub1...", "role": "staff" }role defaults to staff. Accepts npub, nprofile, or hex. 201, or 409 if
they already have access.
{ "role": "admin" }Removes the user and their grants.
Demoting or deleting the last admin is 409 — there is no way to lock every
admin out of the dashboard through the API. (npanel make-admin is the way
back in if it ever happens anyway.)
A tunnel serves a hostname from an agent on another machine. See Tunnels for running one, and the architecture for how it works.
Every tunnel, and every agent waiting to be authorized.
{
"tunnels": [
{
"hostname": "dev.example.com",
"name": "laptop",
"pubkey": "<hex>",
"target": "http://localhost:8080",
"enabled": true,
"created_by": "<hex>",
"created_at": 1754000000,
"updated_at": 1754000000,
"connected_at": 1754000300,
"connections": [
{ "connected_at": 1754000300, "remote": "203.0.113.7", "agent": "npanel/0.7.0" }
],
"certificates": null
}
],
"requests": [
{
"code": "BCDF-GHJK",
"pubkey": "<hex>",
"name": "devbox",
"target": "http://localhost:5173",
"remote": "203.0.113.9",
"created_at": 1754000400,
"expires_at": 1754001300
}
]
}target is what the agent says it forwards to. It is set on the agent's
machine and can't be changed here. connected_at is when an agent last
connected, or null if one never has. certificates is as on a host: null
unless npanel gets certificates itself.
{ "tunnel": { ... } }, one of the tunnels above.
{ "request": { ... }, "from_you": true }, one of the requests above.
from_you says whether the agent asked from the network the admin signing
this request is on (the same IPv4 address, or IPv6 /64), and is null when
either address isn't known. The code may be typed in any case, with or
without its dash. 404 once it has expired (after fifteen
minutes) or been answered.
{ "hostname": "dev.example.com" }Authorizes the agent, which connects within a couple of seconds. 201 with
{ "tunnel": { ... } }. 409 if a site or tunnel already has the hostname, or
it is the dashboard's.
Refuses the agent, which is told so and stops.
{ "hostname": "staging.example.com", "name": "laptop", "enabled": false }Every field is optional. Turning a tunnel off frees its hostname at once and
drops its connections; the agent waits to be turned back on. 409 if the new
hostname is taken.
Revokes the tunnel. Its connections are dropped, its agent stops, and its key works nowhere until it is approved again.
What npanel tunnel calls. Each is signed by the tunnel's key, and none needs
a user. Singular, as /api/me is beside /api/users: /api/tunnel is the
tunnel signing the request. They are documented for completeness; npanel tunnel is the only client.
Where the key stands: { "status": "authorized", "hostname": "...", "enabled": true }, { "status": "pending", "code": "...", "url": "...", "expires_at": ... }, { "status": "denied" } (said once), or { "status": "unknown" }.
{ "name": "laptop", "target": "http://localhost:8080" }Asks to be authorized, and answers as GET /api/tunnel does. Asking again while
waiting keeps the same code. 429 when three agents from the same address
are waiting already, or it has asked ten times in ten minutes.
With Connection: upgrade and Upgrade: npanel-tunnel over HTTP/1.1, answered
with 101, after which the connection is HTTP/2 with npanel as the client. 404
if the key has no tunnel, 403 if the tunnel is off, and 429 if its agents
already hold tunnel_max_connections between them. Each signature connects
once: 401 if this one already has.
{
"settings": {
"relays": ["wss://relay.ditto.pub"],
"blossom_fallback_servers": ["https://blossom.ditto.pub/"],
"max_file_size": 104857600,
"max_site_size": 1073741824,
"max_site_files": 50000,
"deploy_attempts": 3,
"resync_interval": 600,
"dns_targets": [],
"allowed_host_suffixes": [],
"tls_check_limit_per_host": 60,
"tls_check_limit_total": 600,
"acme_directory": "https://acme-v02.api.letsencrypt.org/directory",
"acme_email": "",
"contact_npub": "",
"public_site_list": false
},
"defaults": { "...": "the built-in defaults, for a reset button" }
}Send any subset of the keys. Unknown keys are 400, as is any value that fails
validation, with a message naming the offending key.
{ "settings": { "...": "the new values" }, "changed": ["relays"] }Changes apply immediately — no restart — and each changed key is written to the audit log. See configuration for what each key does.
{
"entries": [
{
"id": 41,
"actor_pubkey": "<hex>",
"action": "host.update",
"target": "example.org",
"old_value": { "...": "before" },
"new_value": { "...": "after" },
"created_at": 1754000000
}
]
}Newest first. limit is clamped to 1–500. Actions are host.create,
host.update, host.delete, host.grants, user.create, user.update,
user.delete, nip05.set, nip05.delete, env.set, env.delete,
settings.update, link_preview.refresh, and link_preview.clear.
{
"found": 1520,
"empty": 41,
"bytes": 9437184,
"in_memory": 830,
"running": 2,
"stats": { "hits": 20410, "lookups": 1930, "runs": 1602, "failures": 3, "busy": 0, "timeouts": 12, "over_budget": 0 }
}What the cache holds: pages with a preview, pages a site's script had nothing
for (remembered for ten minutes at most), and what previews.sqlite3 takes up
on disk. stats count since the server started: answers from memory, lookups
handed to the previews runtime, lookups that ran a site's script, runs whose
script threw or ran out of time, pages sent without a preview because as many
lookups were running as may (busy) or the lookup took too long (timeouts),
and lookups that didn't run a script because the minute's runs were spent.
503 while the previews runtime is starting.
{ "url": "https://ditto.pub/npub1..." }Runs a page's preview script again, whatever is cached, waits for the image it draws, and keeps what it made. The page is asked for as a crawler sending no headers would ask for it. Not held to the concurrency limit or the run budget, which are for crawlers.
{
"url": "https://ditto.pub/npub1...",
"preview": {
"title": "Alex on Ditto",
"description": "Builds things.",
"image": "https://ditto.pub/_npanel/preview/npub1...?v=...",
"type": "profile",
"video": null,
"audio": null,
"published": null,
"article": true
}
}preview is null when the script had nothing for the page. 400 for a URL
that isn't one of the pages its site's script lists in its routes, for a site
with link previews off or no script, and for a script that throws, saying
what it threw; 503 if it takes longer than thirty seconds.
{ "cleared": 1561 }. Forgets every preview and every image drawn, in memory
and on disk, and gives the space back. Each is made again the next time a
crawler asks.
{
"unreferenced": { "blobs": 20, "bytes": 1048576 },
"last_prune": { "finished_at": 1760000000, "versions": 4, "blobs": 120, "bytes": 31457280 }
}unreferenced is what nothing uses any more, which the next prune deletes;
it doesn't count what forgetting versions would then free. last_prune is how
the last automatic prune went, or null if there hasn't been one since the
server started. Apart from /api/status because finding what nothing uses
visits every stored blob, and the footer polls the status.
{
"hosts": 12,
"ready_versions": 30,
"blobs": 1840,
"blob_bytes": 419430400,
"relays": ["wss://relay.ditto.pub"],
"tor": {
"enabled": true,
"connected": true,
"progress": 100,
"services": 12,
"error": ""
},
"tls": {
"directory": "https://acme-v02.api.letsencrypt.org/directory",
"names": 13,
"issued": 12,
"failing": [
{ "hostname": "new.example.org", "expires_at": null, "error": "The CA could not validate new.example.org: …", "failures": 2, "attempted_at": 1754000000 }
]
}
}Available to any authenticated user. blob_bytes counts compressed copies
too: it is the disk the stored files take up.
tor.enabled is the setting; connected is whether npanel is on the Tor
network and ready to answer, progress how far through joining it npanel is,
as a percentage, and services how many onion services are running. error is
empty while nothing is wrong and otherwise says what is, as a sentence — npanel
keeps trying, so it describes a live condition rather than a permanent one. See Tor.
tls is null behind a proxy. With https_port set, names is how many
names may have a certificate, issued how many have an unexpired one from
directory, and failing every wanted name whose last attempt failed, shaped
like a host's certificates. See without a proxy.
Not part of the JSON API, but worth knowing when debugging a served site:
| Header | Meaning |
|---|---|
ETag |
The blob's sha256, quoted, suffixed per encoding |
Last-Modified |
The manifest event's created_at |
Content-Encoding |
br or gzip, when a compressed copy was sent |
Vary |
Accept-Encoding, and User-Agent on a page with a link preview |
Content-Security-Policy |
The site's own policy, or npanel's |
Nsite-Location |
The same page on Nostr, as an nsite:// URL |
Onion-Location |
The site's onion address, when it has one |
Nsite-Location names the nsite this hostname serves and the page within it:
nsite://naddr1…/docs/intro?ref=x for a request for /docs/intro?ref=x, the
path and query carried over as they were asked for, the way Onion-Location
carries them to the onion. An naddr holds the pubkey, kind, and d tag
together, and relays to find the site on, so a client that reads nsites
directly can open the page from this one value. The relays are the first two
of the author's write relays (their NIP-65 list), or of npanel's own relays
when the author lists none, as they stood when the host's build was made. A
host's naddr in the API is the same one. It is sent with every file of the site, and with a page's link
preview, but not with a preview's image, which is npanel's and not a file at
any path of the site.
Content-Security-Policy is whatever the site declared in the <meta> tags of
its /index.html, or a restrictive default when it declared nothing. It is the
same on every response for a host, documents and assets alike, and absent
entirely for a host whose csp flag is off.
npanel stores compressed copies of a site's text files at deploy time and sends
whichever the client can read, preferring the smallest when the client states
no preference of its own. Each encoding has its own ETag — "<sha256>-br",
"<sha256>-gzip", or plain "<sha256>" for the blob — so a cache can't answer
one encoding's conditional request with another's. Content-Length and any
Content-Range describe the copy that was sent, not the blob.
Onion-Location is how Tor Browser offers a visitor the onion without anyone
having to hand the address out. It is sent only on a 200 carrying HTML, and
only over HTTPS — on a plain-HTTP hop anything in the path could rewrite it and
point the visitor at an onion of its own choosing. Requests that already arrived
over Tor never carry it.
Every site response is readable from any origin, headers included:
Access-Control-Allow-Origin and Access-Control-Expose-Headers are both *,
so a cross-origin fetch can revalidate against the ETag, resume from a
Content-Range, and read Nsite-Location or Onion-Location like a same-origin
one. Preflights are answered for GET, HEAD, and OPTIONS. There is nothing
to configure — a site is public files with no credential attached.
A host with NIP-05 names serves them here, in
place of any nostr.json its site deployed. A host with none serves the file
if the site published one, and otherwise 404s like any other missing path.
GET https://example.com/.well-known/nostr.json?name=bob
{ "names": { "bob": "<hex>" }, "relays": { "<hex>": ["wss://relay.example.com"] } }
A name that this host doesn't answer for is { "names": {} } with a 200 —
the question was answered, and the answer is that the domain doesn't know them.
A request with no name returns every name the host has, which is what lets
a crawler discover who a domain hosts; that is the same list a static
nostr.json would have handed over. relays is omitted when no name carries
one.
The response is application/json, cached for a minute, and readable from any
origin like every other site response — which NIP-05 requires, since a
browser-only client can't tell a CORS refusal from a missing file. It is served
even before the site behind the hostname has finished its first deploy: the
names belong to npanel, not to the manifest.
No redirect is ever issued here, per the NIP. Note that www.example.com still
redirects to example.com when that host asked for it, and a fetcher must
ignore the redirect — so publish _@example.com, not _@www.example.com.
Range requests are supported. A 503 with Retry-After means no version is
ready yet; a 404 means the hostname isn't configured, or the path isn't in the
version — served as the site's /404.html when it has one. On a host with spa
set, a document path that isn't in the version is a 200 carrying the site's
shell instead.