Skip to content

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

  • 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.
Terminal window
curl -fsS -H "Authorization: Bearer dak_your_token_here" \
https://console.dednets.com/api/v1/topology

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

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
Terminal window
# 1. record the domain and read back the DNS records to create
curl -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 now
curl -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 it
curl -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 yours
curl -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 it
curl -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):

Terminal window
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.

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.

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

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.

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 an https target 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, a 10.x/192.168.x address). Set origin to "daemon" and origin_daemon to the bare name of a daemon you own; a daemon you do not own is rejected. A daemon-origin https target is checked opportunistically and therefore does not detect certificate expiry (use an edge monitor 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
Terminal window
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/monitors

Body 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.)

Method + path Purpose Scope
GET / POST / DELETE /apitokens manage API tokens signed-in session only, not available to API tokens
Terminal window
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).

To publish a container the host has discovered, name it in container:

Terminal window
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/exports

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

Terminal window
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/exports

Either way, read the assigned hostname from the topology afterwards: the app’s public_host field under its host’s apps list.

Terminal window
curl -fsS -H "Authorization: Bearer $TOKEN" \
https://console.dednets.com/api/v1/topology

To unpublish: DELETE /api/v1/exports?daemon=alice/vm1&name=demo.

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

Terminal window
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.

Terminal window
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/watchdogs

period 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.)

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:

Terminal window
# file it under a group
curl -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 group
curl -fsS -X PATCH -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"group":""}' \
https://console.dednets.com/api/v1/watchdogs/12
Terminal window
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.

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.

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

  • Check the content type. A Console older than v0.4.1 does not know /api/v1 and answers a GET with the web UI’s HTML page and a 200. If your client suddenly fails to parse JSON, confirm the response really is Content-Type: application/json, then upgrade the Console.
  • Watchdog ping URLs can come back relative. On a Console with no public hostname configured, ping_url is 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/daemons and no GET /api/v1/proxy-ports. Both lists come from GET /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.