Reference

HTTP API

Every endpoint, what authorises it, and the error shape they all share.

The control plane is a Rust process that speaks JSON over HTTP and nothing else. It listens on GUARDYN_API_LISTEN, which defaults to 127.0.0.1:8787, and every route is under /v1. The prefix was there in the first commit rather than added when a second version became necessary, because retrofitting a version prefix onto a shipped API is a migration nobody enjoys. There is no separate layer for the dashboard: it calls the same endpoints documented here, which is why a field described on this page can be trusted to exist.

What authorises a request

Two credentials exist, and they are deliberately different Rust types rather than one type carrying an Option nobody checks. A person is a session cookie; an edge node is a bearer token. A node must not be able to do anything a person can, and the way that restriction gets forgotten is a single Auth type with a nullable field.

RequiresMeansFailure
nothingPublic. Sign-up, sign-in, the plan ceilings, the expression reference, health and readiness.
SessionA valid guardyn_session cookie resolving to a user with an active workspace.401 unauthenticated, or 403 no_workspace
Session + roleThe above, plus a membership role at or above the one the route names.403 insufficient_role
OperatorA session whose user carries is_platform_admin.404 not_found
Node tokenAuthorization: Bearer gdn_node_… matching an enabled row in edge.nodes.401 unauthenticated

Roles

Roles are ordered — viewer, analyst, admin, owner — and a requirement is satisfied by that role or anything above it. A membership value this build does not recognise parses as viewer, because defaulting upward is how a typo in a role column becomes a privilege escalation. Reads need only a session; writes that change what visitors see need admin; deleting a zone needs owner, since it takes a site offline. Moving a zone onto another plan is an operator action, because what a plan costs belongs to billing rather than to the edge.

The cookie is guardyn_session — the same name and the same public.sessions row guardyn.dev issues, so one sign-in covers both products. It carries HttpOnly, Path=/, SameSite=Lax and a Max-Age from the session policy. Secure is added in production only, so http://localhost still works; Domain only when GUARDYN_COOKIE_DOMAIN is set, because pinning it to .guardyn.dev unconditionally would break every deployment that is not on that domain. Lax rather than Strict because Strict breaks the return from a checkout and from an emailed verification link. Sign-out clears the cookie with the same attributes it was set with: one that omits Domain cannot delete one that had it, and the symptom is a user who cannot sign out.

calling it with curl
# Sign in. The session arrives as a Set-Cookie, so keep a jar.
curl -sc jar.txt http://127.0.0.1:8787/v1/auth/login \
  -H 'content-type: application/json' \
  -d '{"email":"you@example.com","password":"correct horse"}'

# Every later call reads it back.
curl -sb jar.txt http://127.0.0.1:8787/v1/zones

Same-origin, and the /v1 rewrite

The dashboard runs on a different port from the API, which would normally mean cross-origin requests with credentials. It does not, because next.config.ts rewrites /v1/:path* to the API: the browser only ever talks to its own origin, the cookie rides along without credentials: 'include', and there is no preflight.

next.config.ts
const API = process.env.GUARDYN_API_URL ?? 'http://127.0.0.1:8787';

async rewrites() {
  return [{ source: '/v1/:path*', destination: `${API}/v1/:path*` }];
}

The API still carries a CORS layer for anyone who runs the two apart. It allows exactly one origin — GUARDYN_DASHBOARD_ORIGIN, default http://localhost:3001 — with credentials, the six methods the API uses, and content-type and authorization as headers. In production the allowlist is empty, because the dashboard is same-origin behind the edge and nothing else needs permission.

The error envelope

Every error a handler returns has one shape, produced in one place — each route inventing its own mapping is how an API ends up returning 500 for a validation failure, which is indistinguishable from an API that is broken.

a plan ceiling
HTTP/1.1 402 Payment Required
content-type: application/json

{
  "error": {
    "code": "limit_reached",
    "message": "zones: 3 of 3 used on the free plan"
  }
}

Switch on code; it is stable. message is free to improve and is written for a person: a ceiling names the numbers so the dashboard can offer an upgrade, and a rejected DNS record quotes the offending value with the shape that was expected.

codeStatusWhen
invalid400Input that failed validation. The message is already phrased for a human.
malformed400A value that would not deserialise.
malformed_id400An id that is not a UUID, reaching a handler.
bad_config400A configuration that cannot safely be acted on.
invite_invalid400A sign-up invitation that is expired, used or unknown.
invalid_credentials401Wrong email or wrong password — deliberately one answer for both.
unauthenticated401No cookie, an expired session, or an unknown or disabled node token.
totp_required401The account has TOTP on and the login body carried none.
totp_invalid401The code did not verify.
limit_reached402A plan ceiling. The message carries used, limit and plan.
forbidden403Authenticated, and not allowed to do this.
insufficient_role403The membership role is below what the route requires.
no_workspace403A user with no active organisation on the session.
user_suspended403The account is suspended.
org_suspended403The workspace is suspended; the message says why.
not_found404It does not exist, or it is not yours, or the route is operator-only.
conflict409A uniqueness or state constraint, including a disruptive apply that was not accepted.
email_taken409Sign-up against an address that already has an account.
snapshot_rejected422A snapshot whose signature did not verify, or whose format is newer than this build.
throttled429Too many sign-in attempts. The message names the wait in minutes.
internal500Our failure. The detail is logged; the caller gets "Something went wrong."
unavailable503The database is unreachable, out of connection slots, or behind on migrations.
storevariesA pass-through from a storage error surfaced by the identity layer.

Nothing internal is echoed back. A database failure becomes “The database is unavailable. Try again shortly.”, with tests asserting that neither a connection string nor an unreadable stored password hash reaches the caller. A 5xx is logged at error with the full chain and a 4xx at debug, because a 500 that logs nothing is an outage nobody can diagnose.

Session

EndpointAuthWhat it does
POST /v1/auth/signupnoneBody: name, email, password, optional org_name and invite_token. Creates the user and signs them in. Refused when GUARDYN_SIGNUPS_OPEN is off.
POST /v1/auth/loginnoneBody: email, password, optional totp. Returns the same body as /v1/auth/me and sets the cookie.
POST /v1/auth/logoutSessionRevokes exactly this session — not every session — and clears the cookie.
GET /v1/auth/meSessionThe user, the active workspace with its plan and limits, every workspace they belong to, whether an operator is impersonating, and can_edit.
POST /v1/auth/switch-orgSessionBody: org_id. Changes the active workspace on the session row. A membership that no longer exists answers { "switched": false } rather than an error, so a stale tab is a no-op.
GET /v1/auth/sessionsSessionEvery live session, with ip, user_agent, created_at, last_seen_at and whether it is the current one.
DELETE /v1/auth/sessions/othersSessionRevokes every session except this one. Returns the count.

Zones

EndpointAuthWhat it does
GET /v1/zonesSessionEvery zone in the workspace, each with a record count gathered in one query rather than one per row, plus the zone ceiling and the nameservers to delegate to.
POST /v1/zonesadminBody: name. Normalised first, so a pasted URL works. The ceiling is checked against the count that would result, not the current one.
GET /v1/zones/{zone_id}SessionOne zone with its settings, cache configuration, expected and observed nameservers, and its DNS records.
DELETE /v1/zones/{zone_id}ownerSoft-deletes the zone.
PATCH /v1/zones/{zone_id}/settingsadminBody: settings. Returns the zone as it now is; the audit entry records only the fields that changed.
PUT /v1/zones/{zone_id}/statusadminBody: status, one of active or paused. Anything else is refused by name. Suspension is an operator action, not a customer’s.
PUT /v1/zones/{zone_id}/planOperatorBody: plan_id. A comped trial, a negotiated ceiling, a downgrade after non-payment.
POST /v1/zones/{zone_id}/activationSessionRe-checks the parent delegation and activates the zone if it matches. Answers with delegated, checked_at and a message naming what is wrong.

Creating a zone returns a next_step object holding the nameservers and the sentence to show the customer, so the dashboard renders the instruction without a second call. Changing a plan returns a note saying the change reaches the edge on the next apply, because ceilings are copied into the snapshot rather than read live.

DNS

EndpointAuthWhat it does
GET /v1/zones/{zone_id}/dnsSessionEvery record, with its effective TTL, whether it is proxied and whether it could be, plus the per-zone record ceiling.
POST /v1/zones/{zone_id}/dnsadminBody: name, type, content, and optionally ttl, proxied, comment, priority.
PUT /v1/zones/{zone_id}/dns/{record_id}adminReplaces the record. A record the platform manages is refused with a conflict naming its owner.
DELETE /v1/zones/{zone_id}/dns/{record_id}adminRemoves it.

content is the single line a table row shows, because that is what a person edits, and it is parsed into a typed record before anything is stored. An MX accepts either 10 mx1.example.com in one field or a separate priority, since both are how real UIs do it. A DNS editor that accepts a malformed MX and stores it wrong is the most common failure in this category, and it is discovered by the mail bouncing.

Origins and pools

EndpointAuthWhat it does
GET /v1/zones/{zone_id}/poolsSessionEvery pool with its origins, the pool ceiling, and the load-balancing policies this build accepts with a line on each.
POST /v1/zones/{zone_id}/poolsadminBody: name, policy, and optionally affinity_key, minimum_healthy, region, max_connections, retry_next_on_failure, enabled.
PUT /v1/zones/{zone_id}/default-pooladminBody: pool_id, or null to clear it. The pool must belong to this zone.
PATCH /v1/pools/{pool_id}adminChanges a pool.
DELETE /v1/pools/{pool_id}adminRemoves a pool and its origins.
POST /v1/pools/{pool_id}/originsadminBody: name, address, and optionally port (443), host_header, sni, weight (1), enabled (true), in_maintenance, max_connections.
PATCH /v1/origins/{origin_id}adminChanges one origin.
DELETE /v1/origins/{origin_id}adminRemoves one origin.

A pool is addressed by its own id once it exists rather than nested under its zone: the zone is needed only to create or to list, and a nested path would make every later call carry an id it does not use. Setting a default pool joins through edge.zones on the organisation — without that join a customer could point their zone at another tenant’s pool by id and proxy traffic to a server they do not own, so the join is the whole security of the operation. Clearing it to null is allowed, because it is the state a zone is in before its first pool exists and refusing it would make deleting the last pool impossible.

Rules

EndpointAuthWhat it does
GET /v1/zones/{zone_id}/rulesSessionRulesets by phase, the custom-rule ceiling and how much is used, the full field catalogue and the phase list.
POST /v1/zones/{zone_id}/rulesadminCreates a rule from source text.
PATCH /v1/rules/{rule_id}adminChanges one.
PUT /v1/rules/{rule_id}/positionadminBody: position, 1-based. Returns the whole reordered list, so a client never has to guess what the other positions became.
DELETE /v1/rules/{rule_id}adminRemoves one.
POST /v1/rules/checkSessionParses an expression without storing it. Takes no zone and needs no role beyond a session, because it touches nothing.

Rules go in and come back as source text, not as a JSON tree. The compiled form is stored, but somebody types http.request.uri.path starts_with "/wp-admin" and that is what they should see back, so the parser has an inverse and the round trip is lossless. POST /v1/rules/check is what the editor calls as you type: on success, the canonical form, the fields the expression reads, and which of those are response-phase-only — that last list is how the editor warns you before a save fails. On failure, ok: false with the message and an annotated copy of your source carrying a caret under the offending character.

Configuration

EndpointAuthWhat it does
GET /v1/configSessionWhat is live — version, content hash, who applied it, zone count, bytes, signing key id — and whether anything saved is not yet published.
POST /v1/config/planOperatorThe diff between the live snapshot and the one the database would build now. Consumes no version number.
POST /v1/config/applyOperatorBody: optional message, optional accept_disruption. Builds, signs and activates exactly one new version.
POST /v1/config/rollbackOperatorBody: optional version. Omitted means the live snapshot’s parent.
GET /v1/config/historyOperatorQuery: limit, default 25. The snapshot log, newest first.

Reading what is live is any customer’s business; publishing is not. A snapshot is one global artefact covering every zone on the platform, so applying one publishes everybody’s pending changes at once — which is why editing is per-workspace and applying is an operator action, two permissions rather than one with a comment. GET /v1/config is the endpoint that answers “why is my change not live yet”, in words, for any signed-in user.

What apply does, in order:

  1. Opens the live snapshot with the verifier, so the comparison is against the bytes actually being served.
  2. Builds a preview using the live version number, so a no-op apply consumes nothing. Taking a version and then finding there was nothing to do leaves a hole in a monotonic sequence, which is the kind of thing somebody spends an afternoon investigating.
  3. Compares content hashes. Identical answers { "applied": false, "reason": "no_changes" } with a 200, not an error.
  4. Returns 409 conflict with the plan in the message when it is disruptive and accept_disruption was not sent.
  5. Only then takes a version number and rebuilds with it, so version and content hash are consistent by construction rather than by mutating the preview’s metadata.
  6. Signs, stores, activates, and writes an audit entry.

Rollback answers with what it activated plus a note that is there because it is the thing people assume wrongly: it changes what is served, not what is saved. A zone deleted since that snapshot stays deleted, and the next apply removes it again. Configuration covers the snapshot format and the CLI covers the commands that wrap these routes.

The node side

EndpointAuthWhat it does
GET /v1/node/configNode tokenQuery: have, the version the node is serving; 0 means none. 204 when the node is current, the signed bytes when it is not.
POST /v1/node/ackNode tokenBody: version, and optionally error. Records what the node is serving so propagation lag is visible.

A node polls continuously and the configuration changes rarely, so the common case by an enormous margin is “nothing new” — answered as 204 No Content with the current version in a header, not a JSON body saying so. Nothing published at all is also 204 rather than 404: the node is not wrong to ask. When there is something, the body is the raw signed artefact as application/octet-stream with four headers:

  • x-guardyn-config-version — the version these bytes are.
  • x-guardyn-config-signature — the Ed25519 signature over them.
  • x-guardyn-config-key-id — which key signed it.
  • x-guardyn-config-hash — the content hash.
what a node does
curl -si 'http://127.0.0.1:8787/v1/node/config?have=41' \
  -H 'authorization: Bearer gdn_node_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'

HTTP/1.1 200 OK
content-type: application/octet-stream
x-guardyn-config-version: 42
x-guardyn-config-signature: ...
x-guardyn-config-key-id: ...
x-guardyn-config-hash: ...

A node reporting error on its ack is the most important signal either endpoint carries: the platform is now serving two different configurations, and otherwise nobody would know. It is logged at error and written to the audit log as config.node_rejected.

Nodes

EndpointAuthWhat it does
GET /v1/nodesOperatorEvery node with its addresses, build version, config version, last_seen_at, draining and enabled flags, versions_behind, and the newest snapshot version.
POST /v1/nodesOperatorBody: name, and optionally region and addresses. Issues the token and returns it once.
POST /v1/nodes/{node_id}/tokenOperatorRotates the token. The previous one stops working immediately.
PUT /v1/nodes/{node_id}/drainOperatorBody: draining, a boolean.
PUT /v1/nodes/{node_id}/addressesOperatorBody: addresses. IP literals only — proxied DNS records are answered with these verbatim, so a hostname will not do.
DELETE /v1/nodes/{node_id}OperatorRemoves the node.

A token is 32 random bytes, base64url-encoded, prefixed gdn_node_. Only its SHA-256 is stored, so no endpoint could return it again — which is why registration and rotation say so in the response rather than leaving a client to discover it. The prefix is not decoration: a leaked gdn_node_… in a public repository is findable by secret scanners precisely because it is recognisable. A bad address is rejected by naming the single offending entry, because Postgres would reject it too but its message quotes the whole array literal.

versions_behind is null when a node has never acknowledged a configuration, which is a different state from being up to date and is where a node sits before its first pull. An empty addresses list is legal — it is what a node behind an external load balancer looks like, and means no proxied name resolves to it.

Public reference

EndpointAuthWhat it does
GET /v1/plansnoneEvery plan and its ceilings, read from edge.plan_limits, in presentation order rather than alphabetically.
GET /v1/referencenoneThe expression language: the field catalogue with each field’s type and whether it is response-phase-only, the two indexed forms, every operator, and every phase with a line on what it is for.

Both are deliberately unauthenticated: the pricing page and the expression reference are for people who have not signed up. Serving them from the same build that enforces the ceilings and parses the expressions is what stops the documentation drifting from the product. /v1/plans reads the table an operator may have edited rather than the compiled-in defaults, so a comped ceiling shows correctly for the customer who was promised it. And /v1/reference cannot describe a field this parser does not know — a reference that documents a rejected field is worse than none, because somebody writes a rule from it and the save fails.

Operations

EndpointAuthWhat it does
GET /v1/healthnoneIs this process alive? No dependencies. Returns status, service and the build version.
GET /v1/readynoneCan this process serve? Pings the database and checks the schema is current.

Two endpoints, because they answer different questions and a deployment that conflates them cannot do a rolling restart. /v1/health is what a supervisor restarts on; /v1/ready is what a load balancer takes out of rotation on. A health check that only proves the process is alive reports green through an outage; a readiness check a supervisor restarts on turns a brief database blip into a restart loop. Readiness fails with unavailable when the database is unreachable and with internal when the schema is behind this build, because serving against a schema the binary was not built for produces subtle failures against real data. The port is bound last, after configuration, migration and seeding, so a process that is listening is one that can serve.

Conventions, and what is not here

Successful responses are 200 with a JSON object — including creations, so there is no 201 and no Location header, and deletions, which answer with a body naming what went and often a sentence about the consequence. The one exception is the node config pull, which is 204 or raw bytes. Ids in paths are UUIDv7. Only /v1/config/history takes a limit; the tenant collections are bounded by plan ceilings instead, so a workspace cannot accumulate a page-worth by accident.