Skip to main content
The relay control plane is the account-facing service behind Remote Access. It owns the record of which organization owns which HQ, mints the signed dial tokens an HQ uses to connect out to the relay, and provisions (and revokes) the per-device credentials phones and browsers present to the relay. It is the only service that calls the relay’s administrative API, so no client ever holds the relay’s master secret. This page covers the control plane only. The relay itself — the tunnel your HQ dials into and the routes devices use to reach it — is documented in the Relay API.

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:
The control plane verifies the token’s signature against the sign-in provider’s published keys, and checks that its issuer and audience match the configured sign-in application. It then reads the token’s organization (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:
with status 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 claims segment is JSON: 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 single error 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 permanent gatewayId. 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 clientKindmobile (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

  1. Android: Desktop calls POST /v1/gateways/:id/pairings/pairing-id-v1 and puts the returned credential in the Pair Device QR code.
  2. iOS: after sign-in, the app lists HQs with GET /v1/gateways, then mints its own pairing with clientKind: "mobile". It receives the credential, plus chatToken when the HQ has one registered.
  3. The device presents its credential to the relay — see the Relay API.
Either way the control plane asks the relay to provision the credential, stores its hash, and returns the raw value once.

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:
  1. Mint. The control plane stores a pending pairing and an approval that expires 120 seconds later. Nothing is provisioned on the relay, and no secret is returned.
  2. Show. The web app renders dash-approve:v1:<approvalId> as a QR code.
  3. Review. A signed-in iPhone or iPad scans it and fetches the approval, including the requesting device’s label.
  4. 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 and chatToken for one claim within 120 seconds.
    • Deny: the pending pairing is deleted.
  5. Poll. The web app polls the pairing list. active means approved. A pairing that has disappeared from the list was denied, expired, or removed.
  6. Claim. The web app claims the credential once. A second claim, or one after the 120-second claim window, gets 410.
An approval is decided at most once. A decision that arrives after the approval has expired gets 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.
Response 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.
Response 200
The new token names the account that owns the HQ record and binds to the public key stored at enrollment. Nothing in the request can change either: an HQ cannot move itself to another account or swap its key. Errors 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 listed publicKey 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 as chatToken. 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
The token is never returned by GET /v1/gateways. It is returned only in pairing and claim responses. Errors

DELETE /v1/gateways/:id

Deletes an HQ. The record is kept with status: "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
Errors 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 with clientKind: "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 the pairing-id-v1 route. On the immediate branch it returns only the credential, plus chatToken when one is registered — no pairingId or status:
A gated 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. Response 200
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.
Errors

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. Response 200
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. Response 200
Public keys are not returned. An account with no signers gets { "signers": [] }.

Approvals

GET /v1/approvals/:id

Fetches an approval so a signer can show what it is approving. iOS calls it after scanning a dash-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:
  1. The approval exists and belongs to the calling account. Otherwise 404.
  2. The approval is still pending and unexpired. Otherwise 410. If it expired while pending, it is marked denied and its pending pairing is deleted.
  3. 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.
  4. 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.
On approve, the control plane provisions the credential on the relay (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.