Script the API
Everything the Console UI does is an HTTP call, and the automation subset of it
is published as /api/v1: a stable, additive-only surface for your own
scripts and pipelines. (Since v0.4.1.)
Basics
Section titled “Basics”- Base URL: your Console, for example
https://console.dednets.com. - Auth:
Authorization: Bearer dak_…with a scoped token (API tokens and scopes). - Format: JSON in. Reads answer JSON; some writes answer a short plain-text
confirmation (
exported,assigned,revoked,ok) rather than a JSON body, so do not pipe every response into a JSON parser. Every error is the JSON envelope described below.
curl -fsS -H "Authorization: Bearer dak_your_token_here" \ https://console.dednets.com/api/v1/topologyReference
Section titled “Reference”Paths below are relative to /api/v1.
| Method + path | Purpose | Scope |
|---|---|---|
GET /ca |
the Console’s CA certificate (public, no auth) | none |
GET /info |
bootstrap document: version, control port, domain, CA fingerprint | read, hosts:manage, services:manage, watchdogs:manage, uptime:manage |
GET /limits |
your quota limits and current usage — a 0 limit means no cap (since v0.11.1: admin accounts are uncapped on watchdogs, uptime monitors, signals and notification topics, and report 0 for those) |
read, hosts:manage, services:manage, watchdogs:manage, uptime:manage |
GET /topology |
the whole account view: hosts, apps, proxy ports, gateways, routes | read, hosts:manage, services:manage |
GET /services/uptime |
bucketed availability per service, and per uptime monitor under its <owner>/#uptime/<id> key |
read, services:manage, watchdogs:manage, uptime:manage |
A token carrying only the Notifications (notify) scope reaches none of the
authenticated routes above: it is for the notifications API, and /api/v1
answers it with scope_denied.
| Method + path | Purpose | Scope |
|---|---|---|
POST /tokens |
mint a single-use join token for a new or existing host | hosts:manage |
DELETE /daemons?daemon= |
revoke a host and everything it published | hosts:manage |
POST /daemons/update |
pin a host’s version, opt it out, or apply an update | hosts:manage |
POST /daemons/proxy |
turn a gateway host’s HTTP reverse proxy on or off: {"daemon": "alice/gw1", "enabled": true}. Off by default; off keeps the proxy hosts but stops serving them (since v0.14.0) |
hosts:manage |
Services
Section titled “Services”| Method + path | Purpose | Scope |
|---|---|---|
GET /exports |
published apps | read, services:manage |
POST / DELETE /exports |
publish or unpublish an app | services:manage |
POST /exports with "kind": "proxy" |
a reverse proxy host: daemon, name and upstream (http(s)://host[:port][/prefix]), no container/host/port; options either flat (request_headers, response_headers, upstream_tls_verify, error_page, upstream_host) or nested as proxy, the shape GET returns. Refused unless the host runs the gateway role and its switch is on; the hostname is a separate POST /domains/bindings (since v0.14.0) |
services:manage |
PATCH /exports |
change one setting on a published app without restating it: auth, public, and for a reverse-proxy host upstream or proxy (the options object; since v0.14.0) - only the fields you send change |
services:manage |
GET /app-viewers |
who may open an app that requires a DedNets login | read, services:manage |
POST / DELETE /app-viewers |
allow or revoke one account on such an app | services:manage |
POST / DELETE /proxy-ports |
a local port on one host reaching an app on another | services:manage |
GET /gateways, POST / DELETE /gateways |
publish on a host you own | read for the list, services:manage to change |
GET /health-checks, POST / DELETE /health-checks |
per-app health-check overrides | read for the list, services:manage to change |
Custom domains
Section titled “Custom domains”Since v0.4.5. Serve an app on a domain you own, with an automatic certificate
(Use your own domain). Publishing on a domain is
publishing an app, so the mutations ride services:manage; that includes
POST /domains/verify, which persists the activation, so read does not
cover it.
Since v0.7.0 a domain is owned on its own and hostnames are bound to apps
separately. POST /domains no longer accepts target (see the breaking-change
note below), and POST /domains/bindings is what publishes an app.
| Method + path | Purpose | Scope |
|---|---|---|
GET /domains |
your domains, each with the DNS records to create, its verification state, and its bindings array |
read, services:manage |
POST /domains |
record a domain you own; returns the records to set. Attaches no app. web_hosts (since v0.12.1, front: "global" only) restricts it to some of your web-enabled hosts |
services:manage |
PATCH /domains |
move a domain to another front ({domain, front, node_daemon}) or, since v0.12.1, change which web-enabled hosts serve a global one (web_hosts: absent keeps the current list, [] allows every host again) |
services:manage |
POST /domains/verify |
run the ownership, pointing, and served-certificate checks now, activate on success (the cert result and per-domain cert_ok/cert_error fields are since v0.4.6) |
services:manage |
DELETE /domains?domain=[&force=true] |
remove the domain and tear down its routing. Refused with 409 domain_in_use while hostnames are bound, unless force=true, which removes them too |
services:manage |
GET /domains/bindings[?domain=][&target=] |
the hostnames you have bound and the app each one reaches (since v0.7.0) | read, services:manage |
POST /domains/bindings |
point one hostname at one of your apps (since v0.7.0) | services:manage |
DELETE /domains/bindings?host= |
stop one hostname; the domain stays yours (since v0.7.0) | services:manage |
PATCH /domains/bindings |
turn the browser check on or off for one hostname (since v0.7.1) | services:manage |
# 1. record the domain and read back the DNS records to createcurl -fsS -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -d '{"domain":"blog.example.com","front":"edge"}' \ https://console.dednets.com/api/v1/domains
# 2. once the records exist, verify (repeat until it passes; it is idempotent)curl -fsS -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -d '{"domain":"blog.example.com"}' \ https://console.dednets.com/api/v1/domains/verify
# 3. point the hostname at an app. An exact domain binds to itself:curl -fsS -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -d '{"host":"blog.example.com","target":"alice/vm1/blog"}' \ https://console.dednets.com/api/v1/domains/bindings
# under a wildcard, name the label instead (the two forms are interchangeable):curl -fsS -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -d '{"domain":"*.lab.example.com","label":"api","target":"alice/vm1/api"}' \ https://console.dednets.com/api/v1/domains/bindings
# what is bound right nowcurl -fsS -H "Authorization: Bearer $TOKEN" \ "https://console.dednets.com/api/v1/domains/bindings?domain=*.lab.example.com"
# let non-browser clients reach one hostname: turn the browser check off for itcurl -fsS -X PATCH -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -d '{"host":"api.lab.example.com","bot_protection":false}' \ https://console.dednets.com/api/v1/domains/bindings
# stop one hostname; the domain stays yourscurl -fsS -X DELETE -H "Authorization: Bearer $TOKEN" \ "https://console.dednets.com/api/v1/domains/bindings?host=api.lab.example.com"
# remove the domain and every hostname on itcurl -fsS -X DELETE -H "Authorization: Bearer $TOKEN" \ "https://console.dednets.com/api/v1/domains?domain=blog.example.com&force=true"POST /domains/bindings answers 201 when the hostname is newly bound and 200
when it moved (a hostname serves exactly one app), with the previous binding in
previous. target must be your own app: a share does not extend to custom
domains.
(Since v0.7.1) every binding carries bot_protection, and PATCH /domains/bindings with {"host":..., "bot_protection":false} turns the browser
check off for that one hostname, which is how an API or a webhook receiver
stays reachable while the check is on. It applies to the hostname you name and
nothing else: the same app keeps the check on its {app}-{username}.{domain}
DedNets hostname, which can never be exempted
(Turning off the bot check).
bot_protection is required in the body: an omitted field is a 400, never a
silent “off”.
(Since v0.6.0) the domain may be a wildcard, *.lab.example.com, which
serves every hostname exactly one label under lab.example.com
(One domain for every branch):
curl -fsS -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -d '{"domain":"*.lab.example.com","front":"edge"}' \ https://console.dednets.com/api/v1/domains(Since v0.7.0) a wildcard hosts one app per label: bind each label you want
with POST /domains/bindings, as in the example above. A wildcard has no
hostname of its own, so binding the bare pattern is refused.
The ownership TXT record goes on the base zone
(_dednets-challenge.lab.example.com), not on the * name; the CNAME or A
record goes on the literal *.lab.example.com name. The records array in the
response already spells both out. Because a wildcard has no address of its own,
POST /domains/verify resolves a freshly generated hostname under the zone on
every call, and a domain that is already live keeps serving through two
consecutive missed checks. GET /domains items carry is_wildcard,
probe_host, probe_at and probe_fails (all since v0.6.0) so you can see
which hostname was tested and how many checks have missed.
front is edge (the shared DedNets edge) or node (one of your own hosts
running the entrypoint role; name it in node_daemon). The verify reply
reports both checks separately, so a script can tell a missing TXT record from
a domain that points at the wrong place.
Breaking change in v0.7.0: POST /domains rejects target
Section titled “Breaking change in v0.7.0: POST /domains rejects target”A request that still carries a non-empty target is refused with 400 and the
code domain_target_removed, and nothing is stored. Record the domain, verify
it, then POST /domains/bindings.
Errors worth branching on (since v0.7.0)
Section titled “Errors worth branching on (since v0.7.0)”Every custom-domain failure answers one envelope, on /api/v0 as well as
/api/v1, with a stable code:
{"error":{"code":"domain_in_use","message":"...","status":409, "hosts":["api.lab.example.com","b.lab.example.com"]}}code |
HTTP | Meaning | Retry? |
|---|---|---|---|
domain_target_removed |
400 | POST /domains still carried target |
no |
invalid_request |
400 | malformed name, label or body | no |
host_not_under_domain |
400 | that hostname is not one the named domain can serve (a wildcard covers exactly one label) | no |
forbidden |
403 | the target is not your own app | no |
not_domain_owner |
403 | the domain or binding belongs to another account | no |
quota_exceeded |
403 | you are out of domain slots | no |
not_found |
404 | no such domain, or nothing bound to that hostname | no |
domain_conflict |
409 | the name overlaps one another account has verified | no |
domain_unverified |
409 | you have not proven you own the domain yet | yes - DedNets re-checks every 5 minutes |
host_taken |
409 | that hostname is held by another account | no |
domain_in_use |
409 | delete refused; hosts lists what would stop. Use force=true |
no |
cert_budget_exhausted |
429 | the wildcard’s new-certificate window is full | yes - it is a rolling window; already-issued hostnames keep working |
hosts appears only on domain_in_use. The two “yes” rows are the only ones
where an identical retry can succeed with no change on your side.
Watchdogs
Section titled “Watchdogs”| Method + path | Purpose | Scope |
|---|---|---|
GET /watchdogs |
list monitors with their derived status | read, watchdogs:manage |
POST /watchdogs |
create a monitor | watchdogs:manage |
PATCH / DELETE /watchdogs/{id} |
edit or delete | watchdogs:manage |
POST /watchdogs/{id}/pause, .../resume |
pause or resume | watchdogs:manage |
GET /watchdogs/{id}/history |
uptime buckets and the outage list | read, watchdogs:manage |
Signals
Section titled “Signals”Since v0.9.0. A signal is one record per condition, grouped by a key you choose: repeats under one key fold into that record and only bump its count, so you are notified when a signal opens, reopens or resolves and never on a repeat. For the concepts and the Console UI, see Report a problem your own scripts find.
{id} is the signal’s opaque 32-character id, from a listing. It is not a
number, and there is no sequential id to guess.
| Method + path | Purpose | Scope |
|---|---|---|
GET /signals |
list signals; state (open by default, or resolved or all), group, severity, key (exact), before, limit |
read, signals |
GET /signals/{id} |
one signal with its full payload and its transition history | read, signals |
POST /signals |
report one occurrence; the body carries key plus optional severity, title, detail, group, ttl and payload |
signals |
POST /signals/{id}/resolve |
close it; idempotent, answering {"changed": false} when it was already closed |
signals |
PATCH /signals/{id} |
change group, severity or ttl; "group": "" ungroups it |
signals |
DELETE /signals/{id} |
delete it and its history | your login only |
GET / POST /signals/sources |
list or mint reporting URLs | your login only |
DELETE /signals/sources/{name} |
revoke one reporting URL | your login only |
A report answers {"signal": {...}, "opened": bool, "notified": bool}.
opened is the field worth reading: it says whether this occurrence started
an episode, and therefore notified you, or folded silently into a signal that
was already open.
severity is info, warning (the default), error or critical. ttl is
the seconds of silence after which the Console closes the signal by itself, from
60 to 2592000, defaulting to 3600. payload must be a JSON object of at
most 8 KB: an array or a bare value is a 400, and an oversized one is a 413.
Two things this surface deliberately refuses a token:
- Deleting a signal. No scope covers it. Closing one is reversible, since reporting the same key again reopens the very same record, so a token may do that; destroying its history is not, and no cron job needs to.
- Minting a reporting URL. That is handing out a credential, refused for the same reason a token cannot mint another token.
There is also a separate, unauthenticated ingest endpoint for scripts, which
is not part of /api/v1 because it goes into crontabs and must never be
re-versioned:
POST /api/v0/sig/{reporting-url-id}/{key}Possession of the URL is the whole credential, and it is write-only: it can
report and nothing else. It takes the same JSON body as POST /signals, or a
curl-friendly shorthand of a raw body plus X-Severity, X-Title, X-Group
and X-TTL headers - where a raw JSON object becomes the payload and anything
else becomes the detail. See the guide for the one-liner.
Uptime monitors
Section titled “Uptime monitors”Since v0.4.5. An uptime monitor is a periodic check against a target you name: an HTTP request (with optional status-code and response-header assertions) or a plain TCP connect. Interval defaults to 60 seconds, minimum 30. For the concepts and the Console UI, see Monitor a URL or endpoint with an uptime check.
A monitor has an origin that decides who probes it:
edge(the default): the Console probes from the public internet, so the target must be publicly reachable - private, loopback, link-local and cloud-metadata addresses are refused, and anhttpstarget has its certificate verified.daemon(since v0.5.0): one of your own daemons probes the target from inside its network, so the target may be private (an internal service, a self-hosted app, a10.x/192.168.xaddress). Setoriginto"daemon"andorigin_daemonto the bare name of a daemon you own; a daemon you do not own is rejected. A daemon-originhttpstarget is checked opportunistically and therefore does not detect certificate expiry (use anedgemonitor for a public HTTPS endpoint if you want cert-expiry alerting).
Read a monitor’s history from GET /services/uptime (it appears as the target
<your-username>/#uptime/<monitor-id>, returned as key when you create it).
| Method + path | Purpose | Scope |
|---|---|---|
GET /uptime/monitors |
list your monitors with their latest result | read, uptime:manage |
GET /uptime/monitors/{id}/outages |
that monitor’s outages, newest first (since v0.7.1) | read, uptime:manage |
POST /uptime/monitors |
create a monitor | uptime:manage |
PATCH / DELETE /uptime/monitors/{id} |
edit or delete | uptime:manage |
POST /uptime/monitors/{id}/pause, .../resume |
pause or resume probing | uptime:manage |
curl -fsS -X POST -H "Authorization: Bearer dak_your_token_here" \ -H 'Content-Type: application/json' \ -d '{"name":"site","target":"https://example.com/health","interval":60,"codes":"200-299"}' \ https://console.dednets.com/api/v1/uptime/monitorsBody fields: name (unique per account), target (an absolute http(s):// URL,
or host:port when kind is tcp), kind (http by default), origin
(edge by default, or daemon), origin_daemon (required when origin is
daemon: the bare name of a daemon you own), interval seconds, group
(optional, display-only; see below), codes (a comma list like 200-299,301;
empty means any HTTP response), and headers (up to five assertions of
{"name","op":"exists|equals|contains","value"}). A monitor alerts to your
uptime notification topic after 3 consecutive failed checks, and sends the
recovery after 2 successes.
group is optional and purely for display: it buckets related monitors together
in the Console. Same charset as a name (letters, digits, - and _, 1 to 64
characters), and there is nothing to create first - a group exists as soon as a
monitor names it. PATCH only touches the fields you send, so an absent group
leaves the monitor where it is; send {"group": ""} to ungroup one. Responses
omit the field entirely for an ungrouped monitor. (Since v0.6.2.)
Account
Section titled “Account”| Method + path | Purpose | Scope |
|---|---|---|
GET / POST / DELETE /apitokens |
manage API tokens | signed-in session only, not available to API tokens |
Recipes
Section titled “Recipes”Enroll a host
Section titled “Enroll a host”curl -fsS -X POST -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' \ -d '{"daemon":"vm1","ttl_seconds":3600}' \ https://console.dednets.com/api/v1/tokens# -> {"token":"...","daemon":"alice-vm1"}daemon in the reply is the name the token actually binds: for a new host the
Console derives <username>-<host>. The token is single-use. Then run the
install command on the machine itself: see
Deploy a daemon, or let an agent hand you
the exact command (Let an AI agent run DedNets).
Publish a service as a public HTTPS URL
Section titled “Publish a service as a public HTTPS URL”To publish a container the host has discovered, name it in container:
curl -fsS -X POST -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' \ -d '{"daemon":"alice/vm1","container":"my-app","name":"demo","port":8080,"proto":"http","public":true}' \ https://console.dednets.com/api/v1/exportsOmit container and you get a host-port export instead: the daemon dials
its own host (127.0.0.1 unless you set host), which is what you want for a
service running directly on the machine rather than in a container. name is
then required, since there is no container name to fall back on.
curl -fsS -X POST -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' \ -d '{"daemon":"alice/vm1","name":"demo","port":8080,"proto":"http","public":true}' \ https://console.dednets.com/api/v1/exportsEither way, read the assigned hostname from the topology afterwards: the app’s
public_host field under its host’s apps list.
curl -fsS -H "Authorization: Bearer $TOKEN" \ https://console.dednets.com/api/v1/topologyTo unpublish: DELETE /api/v1/exports?daemon=alice/vm1&name=demo.
Put an app behind a DedNets login
Section titled “Put an app behind a DedNets login”(Since v0.10.0) Add "auth":"dednets" to the publish call and only you and the
accounts you allow can open the URL - everyone else is stopped at the DedNets
edge. http apps only. See Put an app behind a DedNets
login.
curl -fsS -X POST -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' \ -d '{"daemon":"alice/vm1","container":"my-app","name":"dash","port":3000,"proto":"http","public":true,"auth":"dednets"}' \ https://console.dednets.com/api/v1/exports
# let another DedNets account in (you, the owner, are always allowed)curl -fsS -X POST -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' \ -d '{"target":"alice/vm1/dash","viewer":"bob"}' \ https://console.dednets.com/api/v1/app-viewers
curl -fsS -H "Authorization: Bearer $TOKEN" \ 'https://console.dednets.com/api/v1/app-viewers?target=alice/vm1/dash'Revoke with DELETE /api/v1/app-viewers?target=alice/vm1/dash&viewer=bob. It
applies on that person’s next request.
There is no API for signing a visitor in: that flow is a browser redirect through the Console, and minting a session for someone’s private app is deliberately not something an API token can do.
Create a watchdog and ping it
Section titled “Create a watchdog and ping it”curl -fsS -X POST -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' \ -d '{"name":"nightly-backup","period":86400,"grace":3600,"group":"backups"}' \ https://console.dednets.com/api/v1/watchdogsperiod and grace are seconds. The reply carries ping_url; have the job
curl it after every successful run. That ping URL is a capability: it
lives at /api/v0/wd/<address> and stays there unversioned, because it is
baked into cron jobs. See
Get alerted when a cron job stops running.
group is optional and purely for display: it buckets related monitors
together in the Console and in listings. Same charset as a name (letters,
digits, - and _, 1 to 64 characters). There is nothing to create first - a
group exists as soon as a watchdog names it, and disappears when the last one
leaves. Omit it for an ungrouped watchdog; responses leave the field out
entirely when a watchdog has no group. (Since v0.4.1.)
Move a watchdog between groups
Section titled “Move a watchdog between groups”PATCH only touches the fields you send, so an absent group leaves the
watchdog where it is. An empty string is the way to ungroup one:
# file it under a groupcurl -fsS -X PATCH -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' \ -d '{"group":"db-prod"}' \ https://console.dednets.com/api/v1/watchdogs/12
# remove it from its groupcurl -fsS -X PATCH -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' \ -d '{"group":""}' \ https://console.dednets.com/api/v1/watchdogs/12Read uptime
Section titled “Read uptime”curl -fsS -H "Authorization: Bearer $TOKEN" \ "https://console.dednets.com/api/v1/services/uptime?window=day"window is hour, day or week; add &target=alice/vm1/demo for one
service.
Errors
Section titled “Errors”Every /api/v1 failure is one JSON envelope:
{"error":{"code":"not_found","message":"...","status":404}}Codes: invalid_request, unauthorized, forbidden, scope_denied,
quota_exceeded, not_found, method_not_allowed, conflict, too_large,
rate_limited, internal, and error (the fallback for any status the Console
does not map). Branch on code, never on the message text.
What “stable” means here
Section titled “What “stable” means here”- Routes and response fields may be added, never removed or renamed.
- Error codes may be added, never renamed.
- Capability URLs (
/api/v0/wd/…,/install.sh,/dist/…) are deliberately unversioned and stay put.
And the honest boundary: /api/v0 is what the web UI uses. It is not a
contract, it can change without notice, and your scripts should use /api/v1.
Good to know
Section titled “Good to know”- Check the content type. A Console older than v0.4.1 does not know
/api/v1and answers a GET with the web UI’s HTML page and a200. If your client suddenly fails to parse JSON, confirm the response really isContent-Type: application/json, then upgrade the Console. - Watchdog ping URLs can come back relative. On a Console with no public
hostname configured,
ping_urlis a path such as/api/v0/wd/…. Join it to your Console base URL before handing it to a job on another machine. - There is no
GET /api/v1/daemonsand noGET /api/v1/proxy-ports. Both lists come fromGET /api/v1/topology. - Install commands assume HTTPS on port 443. A self-hosted Console served on another port emits an install command whose download URL does not resolve.
See also
Section titled “See also”- Let an AI agent run DedNets for the no-code path
- API tokens and scopes