Base URL
The control plane is a standalone HTTP service. Every client is configured with its base URL (scheme and host, no path) and appends the paths on this page to it:
A self-hosted control plane listens on port
9400 by default. See Configuration.
Authentication
The control plane has three authentication schemes, one per route group.Account bearer (/v1/*)
Every /v1/* request must carry the ID token from your DashSquad sign-in:
org_id) and uses it as the account that owns everything you
create. A token without an organization is rejected; there is no fallback to the individual
user. This is why Remote Access asks you to pick an organization when
you sign in.
A missing, malformed, expired, or organization-less token gets:
401.
Every /v1/* route is scoped to that account. Asking about an HQ, pairing, or approval that
belongs to another account returns 404, exactly as if it did not exist — the control plane never
confirms that someone else’s resource exists.
A development build can instead trust an
x-test-account header as the account ID. The server
enables this only when it is explicitly started with RELAY_CP_DEV_STUB_AUTH=1 or
NODE_ENV=development, and never while sign-in credentials are configured. Never enable it on a
reachable server.HQ assertion (/gw/dial-token)
An HQ does not sign in to an account. It proves it holds the Ed25519 private key whose public key
was registered when the HQ was enrolled, by sending a short-lived signed assertion:
The signature is Ed25519 over the base64url claims segment. There is no algorithm field. The
cp-dial-token audience keeps an assertion made for the control plane from being replayed at
the relay, whose dial-in proof uses a different audience. DashSquad’s HQ signs a fresh assertion
for every request, valid for 60 seconds.
Timestamps
Every timestamp in/v1/* responses — createdAt, expiresAt, approvalExpiresAt — is unix
milliseconds. The claims inside dial tokens and HQ assertions use unix seconds.
Errors
Errors are JSON objects with a singleerror string, for example
{ "error": "HQ not found" }. The one exception is the credential-claim route’s
“not decided yet” answer, which is { "status": "pending" } (see
Claim an approved credential).
An unexpected server-side failure, such as the relay’s administrative API being unreachable
during a revoke, surfaces as a 500.
Route index
DashSquad for Android never calls the control plane. It receives its relay address and device
credential from the QR code Desktop shows, which Desktop minted through the pairing route.
Concepts
HQ (gateway). An enrolled HQ is identified by the address label you chose, which is also its permanentgatewayId. Its public relay address is <label>.<relay zone>. Paths and fields keep
the older gateway name. An HQ record is active until it is deleted, after which it stays in the
list as revoked forever; the label is never released.
Dial token. A control-plane-signed token the HQ presents to the relay when it connects out. It
names the owning account (tenantId), the gatewayId, an expiry, and the HQ’s public key
(cnf), so only the holder of the matching private key can use it. Tokens last 24 hours by
default. The HQ gets its first token at enrollment and refreshes it itself through
POST /gw/dial-token.
Pairing. One device’s credential for reaching an HQ through the relay. The relay generates the
credential and the control plane returns it to the caller exactly once; the control plane stores
only a SHA-256 hash of it. Each pairing has a clientKind — mobile (the default) or web — and
a status:
A pending pairing never becomes
revoked. It either becomes active on approval, or is deleted
outright when it is denied, expires, or is removed.
Web chat token. A browser has no QR code to receive the HQ’s phone-scoped chat capability, so
Desktop registers that capability with the control plane, which hands it back with each browser
pairing as chatToken. It is the same phone-scoped bearer a pairing QR carries, never the HQ’s
administrative token.
Signer. A device, currently an iPhone or iPad signed in to the account, that registered an
Ed25519 public key. Once an account has at least one signer, every new browser pairing must be
approved by one. Mobile pairings are never gated, and accounts with no signers mint browser
pairings immediately.
Approval. A single-use, 120-second challenge created for a gated browser pairing. A signer
approves or denies it once with a signature over its ID. After approval, the browser has another
120 seconds to claim the credential.
Flows
Enroll an HQ
Enrollment makes no call to the relay. The relay trusts the dial token because the control plane signed it. The HQ refreshes its token when it starts with one that is close to expiry, on a timer before expiry, and whenever the relay rejects its current token.Pair a phone
- Android: Desktop calls
POST /v1/gateways/:id/pairings/pairing-id-v1and puts the returned credential in the Pair Device QR code. - iOS: after sign-in, the app lists HQs with
GET /v1/gateways, then mints its own pairing withclientKind: "mobile". It receives the credential, pluschatTokenwhen the HQ has one registered. - The device presents its credential to the relay — see the Relay API.
Pair a browser with signer approval
When the account has no signers,POST .../pairings/pairing-id-v1 with clientKind: "web"
returns the credential and chatToken immediately, like a phone. With at least one signer:
- Mint. The control plane stores a
pendingpairing and an approval that expires 120 seconds later. Nothing is provisioned on the relay, and no secret is returned. - Show. The web app renders
dash-approve:v1:<approvalId>as a QR code. - Review. A signed-in iPhone or iPad scans it and fetches the approval, including the requesting device’s label.
- Decide. The signer signs its decision (see
Record a decision) and posts it.
- Approve: the control plane provisions the credential on the relay, marks the pairing
active, and holds the credential andchatTokenfor one claim within 120 seconds. - Deny: the pending pairing is deleted.
- Approve: the control plane provisions the credential on the relay, marks the pairing
- Poll. The web app polls the pairing list.
activemeans approved. A pairing that has disappeared from the list was denied, expired, or removed. - Claim. The web app claims the credential once. A second claim, or one after the 120-second
claim window, gets
410.
410, and the expired approval and its pending pairing are cleaned up at that point.
Health
GET /health
Reports that the control plane is up and lists the optional capabilities it supports. No authentication is required.200
pairing-id-v1 means the server supports POST /v1/gateways/:id/pairings/pairing-id-v1, which
returns a pairing ID with the credential. Desktop checks for it before minting a pairing and
refuses to mint against a control plane that lacks it. The capability name is also part of the
route path, so an older server answers that path with 404 rather than minting a credential the
client cannot identify.
HQ dial tokens
POST /gw/dial-token
Mints a fresh dial token for the HQ that signed the assertion. Called only by the HQ, never by a browser or an account-authenticated client. Auth: HQ assertion. No request body.200
Every failure returns the same
401. Deleting an HQ is enough to stop it refreshing.
HQs
POST /v1/gateways
Enrolls a new HQ at the address label you choose. Called by Desktop’s Create HQ. Request body
A label must be 1–63 characters of lowercase
a-z, 0-9, and -, with no hyphen at either end.
These labels are reserved: www, api, admin, relay, health, gw, mc, app, control,
status. A label that has ever been claimed, including by a deleted HQ, is never available again.
The control plane only checks that publicKey is non-empty. Dial tokens bind to this exact
value, so the key must be the HQ’s raw base64url Ed25519 key for relay dial-in and
POST /gw/dial-token to work.
Response 200
The HQ’s owner is the calling account. The control plane does not contact the relay on
enrollment.
Errors
GET /v1/gateways
Lists the HQs owned by the calling account, including deleted ones. Desktop, iOS, and the web app use it to show your HQs; iOS also compares an HQ’s relay-verified identity with the listedpublicKey before connecting.
Response 200
The response never includes the owning account or the registered web chat token. An account with
no HQs gets
{ "gateways": [] }.
GET /v1/subdomains/:label
Checks whether an address label can be claimed. Desktop calls it as you type in Create HQ.
Response
200
available is true only when the label is valid, not reserved, and has never been claimed. An
invalid label returns { "available": false }, not an error. The check covers every account.
PUT /v1/gateways/:id/web-chat-token
Registers the chat-scoped bearer that browser pairings for this HQ receive aschatToken. Desktop
uploads it when it enrolls the HQ and again whenever it refreshes enrollment. The latest value
replaces any earlier one.
Request body
Response
200
GET /v1/gateways. It is returned only in pairing and claim
responses.
Errors
DELETE /v1/gateways/:id
Deletes an HQ. The record is kept withstatus: "revoked" and its label is never released. The
control plane then asks the relay to close the HQ’s live tunnel immediately
(POST /admin/gateways/revoke). After deletion, the HQ can no longer refresh its dial token.
Response 200
A cross-account request changes nothing and never reaches the relay.
Pairings
POST /v1/gateways/:id/pairings/pairing-id-v1
Mints a device pairing for one of your HQs. Desktop calls it for the Android Pair Device QR code, iOS calls it withclientKind: "mobile" when you pick an HQ, and the web app calls it with
clientKind: "web".
Request body (optional; an empty or missing body is accepted)
Response
200 — immediate pairing
Returned for every mobile pairing, and for a web pairing when the account has no signers.
The control plane asks the relay to provision the credential (
POST /admin/pairings), stores
only its SHA-256 hash, and records the pairing.
Response 200 — pending approval
Returned for a web pairing when the account has at least one signer. No credential is
provisioned and no secret is returned.
Continue with the browser approval flow.
Errors
POST /v1/gateways/:id/pairings
The original pairing route, kept for older Desktop releases. It takes the same body, applies the same signer gate, and returns the same errors as thepairing-id-v1 route. On the immediate
branch it returns only the credential, plus chatToken when one is registered — no pairingId or
status:
web pairing returns the same pending shape as the pairing-id-v1 route. The control
plane still records a pairing ID, so the device appears in the pairing list and can be revoked.
New clients should use the pairing-id-v1 route.
GET /v1/gateways/:id/pairings
Lists every pairing for one of your HQs. Desktop shows these under Paired devices. The web app polls this list during browser approval. Response200
Revoked pairings stay in the list, so filter or label them yourself. Denied and expired pending
pairings are deleted and disappear. Credential hashes are never returned.
Errors
An HQ you own with no pairings returns
{ "pairings": [] }.
DELETE /v1/gateways/:id/pairings/:pid
Revokes one device. Called by Desktop’s Revoke, by iOS’s Disconnect & Forget, and by the web app.
Response
200
- Active pairing: marked
revoked. The control plane then tells the relay to drop exactly that credential by its hash (POST /admin/pairings/revoke). Other devices keep working. - Pending pairing: deleted from the control plane. The relay is not contacted because no credential was ever minted.
POST /v1/gateways/:id/pairings/:pid/credential
Claims the credential and chat token held for an approved browser pairing. Called by the web app’s approval poll loop. The credential can be claimed once, within 120 seconds of approval. No request body. Response200
chatToken is omitted if none was registered when the pairing was approved.
Other responses
Signers
POST /v1/signers
Registers the calling device’s Ed25519 public key as a signer for the account. iOS registers before it approves its first browser pairing. Request body
Response
201
signerId is sg- followed by 12 hex characters. Registration is idempotent per account and
key: registering the same key again returns the same signerId and updates its label.
Registering the first signer turns on approval for all future browser pairings on the account.
There is no route to remove a signer.
Errors
GET /v1/signers
Lists the account’s signers. Response200
{ "signers": [] }.
Approvals
GET /v1/approvals/:id
Fetches an approval so a signer can show what it is approving. iOS calls it after scanning adash-approve:v1:<approvalId> QR code.
Response 200
The response has no status field and is returned even after the approval is decided or expired.
A signer learns that it is too late from the decision route’s
410.
Errors
POST /v1/approvals/:id/decision
Records a signer’s approve or deny decision. Request body
The signed message is these three values joined by newline characters and encoded as UTF-8, with
no trailing newline:
decision is the literal approve or deny. Signing binds the decision to both the approval and
the pairing.
Response 204 with an empty body, for both approve and deny. Poll the pairing list or claim
route to see the result.
The control plane checks the request in this order:
- The approval exists and belongs to the calling account. Otherwise
404. - The approval is still pending and unexpired. Otherwise
410. If it expired while pending, it is marked denied and its pending pairing is deleted. - The signer belongs to the account and the signature verifies. Otherwise
403. A failed attempt does not consume the approval, so the real signer can still decide it. - The approval moves to approved or denied in one atomic step that rechecks the expiry. If
another decision or the deadline wins the race, the result is
410.
POST /admin/pairings),
marks the pairing active, and holds the credential and current web chat token for one claim
within 120 seconds. If the pairing was removed while the signer was deciding, the new credential
is revoked on the relay right away and the response is 410.
On deny, the pending pairing is deleted.
Errors
Browser access (CORS)
The web app calls the control plane from another origin, so/v1/* supports CORS for an explicit
allowlist of origins set with RELAY_CP_WEB_ORIGINS. Origins must match exactly — no wildcards or
subdomain matching — and credentials (cookies) are never allowed, because auth is a bearer header.
For an allowlisted origin the control plane allows the methods GET, POST, PATCH, DELETE,
and OPTIONS, and the headers Authorization and Content-Type. Preflight requests are answered
before authentication. Other origins get no CORS headers. With an empty allowlist, the default,
CORS is off.
/health and /gw/dial-token never send CORS headers. /gw/dial-token is only for HQs.
Configuration
Settings are read from command-line flags, then environment variables, then defaults.
The server refuses to start without the relay admin secret and dial-token key. It also refuses to
start unless both
RELAY_CP_CLERK_* variables are set, or development auth is explicitly enabled
(RELAY_CP_DEV_STUB_AUTH=1 or NODE_ENV=development). When both are configured, sign-in wins.
Only a SHA-256 hash of each device credential is stored. Two recoverable secrets are stored in the
database: each HQ’s registered web chat token, and an approved browser credential until it is
claimed or its 120-second claim window passes. Protect the database file accordingly.