dashsquad

Relay Control Plane API

HTTP API to enroll HQs on the hosted relay, pair devices and approve browser sessions.

The relay control plane is the account service behind Remote access. It keeps the record of which organization owns which HQ. It signs the dial tokens that an HQ uses to connect out to the relay. It also adds and revokes the credentials that phones and browsers give to the relay. Only the control plane calls the admin API of the relay. Thus no client has the master secret of the relay.

This page is about the control plane only. The Relay API page tells about the relay: the tunnel that your HQ connects to, and the routes that devices use.

Base URL

The control plane is a separate HTTP service. Each client has a base URL (scheme and host, no path) and adds the paths on this page to it:

ClientSource of the base URL
DesktopIts control-plane setting. Desktop uses it after you sign in, in the setup wizard or in the Account card of Settings → General
iOS appThe DashControlPlaneURL value in the app
web appThe controlPlaneUrl value in its /config.js file. If that value is empty, the VITE_CONTROL_PLANE_URL build setting
HQThe --control-plane-url flag

Authentication

The control plane has three authentication schemes, one for each route group.

RoutesSchemeHeader
GET /health, GET /version, GET /readyNoneNone
All /v1/* routesdashsquad account sign-in (Clerk ID token or session token)Authorization: Bearer <token>
POST /gw/dial-token, POST /gw/versionAn assertion that the HQ signs with its private keyAuthorization: Bearer <assertion>

Account bearer (/v1/*)

Each /v1/* request must have a token from your dashsquad sign-in:

Authorization: Bearer <your-token>

The control plane does a check of the token signature against the published keys of the sign-in provider. It also checks that the issuer agrees with the sign-in application. It accepts two kinds of token:

TokenSent byOther checksOrganization
OIDC ID tokeniOS app, old Desktop releasesThe audience (aud) must be the sign-in applicationorg_id
Session token, version 2Desktop, web appThe token has no aud. It must have v: 2 and a session ID (sid). Its azp must be exactly a permitted origin (the same list as Browser access)o.id

If the control plane has no permitted browser origins, it refuses all session tokens. The organization of the token is the account that owns all that you make.

The control plane refuses a token without an organization. It does not use the individual user as a fallback. For this reason, Desktop selects an organization when you sign in. If your account has no organization, Desktop makes one.

A token that is missing, malformed, expired, or without an organization gets status 401:

{ "error": "unauthorized" }

Each /v1/* route applies to that account only. A request about an HQ, pairing or approval of a different account returns 404. This is the same response as for an item that does not exist. Thus the control plane does not tell you that an item of a different account exists.

HQ assertion (/gw/*)

An HQ does not sign in to an account. Each request has a short-life signed assertion. This assertion shows that the HQ has the Ed25519 private key for the public key that it registered at enrollment:

Authorization: Bearer <base64url(claims)>.<base64url(signature)>

The claims segment is JSON:

ClaimTypeDescription
gatewayIdstringThe ID of the HQ: a UUID, or the address label of an old HQ
audstringMust be exactly the audience of the route: cp-dial-token for POST /gw/dial-token, cp-hq-version for POST /gw/version
iatnumberTime of issue, unix seconds
expnumberExpiry, unix seconds. The control plane refuses the assertion when exp is not in the future

The signature is Ed25519 over the base64url claims segment. There is no algorithm field. The control plane finds the HQ from gatewayId and does a check of the signature against the public key of that HQ. The HQ must be active.

Each route accepts only its own audience. Thus nobody can use a cp-dial-token assertion on POST /gw/version, or a cp-hq-version assertion on POST /gw/dial-token. The relay dial-in proof uses a different audience, relay-dial. Thus nobody can use an assertion for the control plane again at the relay. The dashsquad HQ signs a new assertion for each request, with a life of 60 seconds.

Timestamps

All timestamps in /v1/* responses are unix milliseconds. These are createdAt, expiresAt and approvalExpiresAt. The claims in dial tokens and HQ assertions use unix seconds.

Errors

An error is a JSON object with one error string, for example { "error": "HQ not found" }. There is one exception: the "not decided yet" answer of the credential claim route is { "status": "pending" }. Refer to POST /v1/gateways/:id/pairings/:pid/credential. An unexpected server failure returns 500. An example is a relay admin API that does not answer during a revoke.

If the database of the control plane is not available, a route returns 503 with { "error": "database_unavailable" }. Try the request again later.

Each response, except from /health, /version and /ready, has an X-Correlation-ID header. Give this value when you report a problem. You can send your own X-Correlation-ID header. The control plane uses it when it has 1 to 128 characters from A-Z, a-z, 0-9, ., _, : and -.

Route index

MethodPathAuthCalled by
GET/healthNoneDesktop
GET/versionNoneNo dashsquad client at this time
GET/readyNoneNo dashsquad client at this time
POST/gw/dial-tokenHQ assertionHQ
POST/gw/versionHQ assertionHQ
POST/v1/gatewaysAccountDesktop
GET/v1/gatewaysAccountDesktop, iOS app, web app
PATCH/v1/gateways/:idAccountDesktop
GET/v1/subdomains/:labelAccountOld Desktop releases
PUT/v1/gateways/:id/web-chat-tokenAccountDesktop
DELETE/v1/gateways/:idAccountNo dashsquad client at this time
POST/v1/gateways/:id/pairings/pairing-id-v1AccountDesktop, iOS app, web app
POST/v1/gateways/:id/pairingsAccountOld Desktop releases
GET/v1/gateways/:id/pairingsAccountDesktop, web app
DELETE/v1/gateways/:id/pairings/:pidAccountDesktop, iOS app, web app
POST/v1/gateways/:id/pairings/:pid/credentialAccountweb app
POST/v1/signersAccountiOS app
GET/v1/signersAccountDesktop
GET/v1/approvals/:idAccountiOS app
POST/v1/approvals/:id/decisionAccountiOS app
POST/v1/eventsAccountDesktop, iOS app, web app
GET/v1/flagsAccountDesktop, iOS app, web app
GET/v1/analyticsAccountDesktop, iOS app, web app
PUT/v1/analyticsAccountDesktop, iOS app, web app

The Android app does not call the control plane. It gets its relay address and its device credential from the QR code that Desktop shows. Desktop gets that credential from the pairing route.

Concepts

HQ (gateway). The address label that you select identifies an enrolled HQ. This label is also its permanent gatewayId. Its public relay address is <label>.<relay zone>. Paths and fields use the old name gateway.

An HQ record is active until you delete it. After that, it stays in the list as revoked. The control plane does not release the label again.

Each time the HQ starts in relay mode with a control-plane URL, it sends its software version to the control plane. If the report fails, the HQ retries until the control plane accepts it or the HQ stops. A failed report does not stop the HQ. Clients read the last accepted version as hqVersion in the HQ list.

Dial token. The control plane signs this token. The HQ gives it to the relay when it connects out. The token names the account that owns the HQ (tenantId), the gatewayId, an expiry and the public key of the HQ (cnf). Thus only the HQ with the related private key can use it.

A token is valid for 24 hours by default. The HQ gets its first token at enrollment. After that, the HQ gets new tokens through POST /gw/dial-token.

Pairing. The credential of one device for one HQ through the relay. The relay makes the credential. The control plane gives it to the caller one time only and keeps only a SHA-256 hash. Each pairing has a clientKind, mobile (the default) or web, and a status:

StatusMeaning
activeThe credential is live on the relay
pendingA device pairing waits for the approval of an active signer. No credential exists yet
revokedThe credential is revoked. The control plane keeps the record

A pending pairing does not become revoked. It becomes active when a signer approves it. The control plane deletes it when a signer denies it, when it expires, or when you remove it.

Web chat token. A browser has no QR code to get the phone chat token of the HQ. Thus Desktop registers that token with the control plane. The control plane then gives it back as chatToken with each browser pairing. It is the same phone bearer token that a pairing QR code has. It is not the administrative token of the HQ.

Signer. A device that registered an Ed25519 public key. At this time, a signer is an iPhone or iPad that is signed in to the account. A signer has an active or pending status. Only an active signer can approve a device.

The control plane supports two device-trust modes. Its default is observe:

ModeAccount has no active signerAccount has an active signer
observeAll pairings return a credential immediatelyBrowser pairings need approval. Mobile pairings return a credential immediately
enforceAll pairings return a credential immediatelyAll pairings need approval, unless the request has a valid proof from an active signer

A client must accept both the active and pending response shapes. The server mode determines which rule applies.

Approval. A single-use challenge for a device pairing or a new signer that needs approval. It is valid for 120 seconds. A signer approves or denies it one time, with a signature over its ID. After approval, the client has 120 more seconds to claim the credential of an approved pairing.

Flows

Enroll an HQ

Legacy label path: an old client can send a subdomain label and no gatewayId. That client can call GET /v1/subdomains/:label first to see if the label is free. Current Desktop does not do this.

Enrollment does not call the relay. The relay trusts the dial token because the control plane signed it. The HQ gets a new token at these times:

  • When it starts with a token that is near its expiry.
  • Before the token expires. The HQ adjusts this time for tokens with a short lifetime.
  • When the relay refuses its current token.

Pair a phone

In observe mode, the phone flow returns a credential immediately:

  1. Android: Desktop calls POST /v1/gateways/:id/pairings/pairing-id-v1. Desktop puts the credential in the Pair Device QR code.
  2. iOS: After sign-in, the app gets the list of HQs with GET /v1/gateways. The app then makes its own pairing with clientKind: "mobile". The response has the credential. It also has chatToken if the HQ has a registered web chat token.
  3. The device gives its credential to the relay. Refer to the Relay API.

In the two cases, the control plane tells the relay to add the credential. The control plane keeps the hash and gives the credential to the caller one time.

In enforce mode, an account with an active signer needs a valid signer proof or approval first. A pending response contains no credential. Show the approval QR code, then claim the credential after approval. Desktop shows the Android pairing QR code only after that claim. Refer to Remote access.

Pair a browser with signer approval

If the account has no active signers, POST .../pairings/pairing-id-v1 with clientKind: "web" returns the credential and chatToken immediately.

With an active signer, the browser follows this sequence. In enforce mode, a valid signer proof can give an immediate credential instead.

Mint

The control plane keeps a pending pairing and an approval. The approval expires after 120 seconds. The control plane does not add a credential on the relay and does not return a secret.

Show

The web app shows dash-approve:v1:<approvalId> as a QR code.

Review

A signed-in iPhone or iPad scans the QR code. It gets the approval, with the label of the browser.

Decide

The signer signs its decision and sends it. Refer to POST /v1/approvals/:id/decision.

  • Approve: The control plane adds the credential on the relay and marks the pairing active. It keeps the credential and chatToken for one claim in the next 120 seconds.
  • Deny: The control plane deletes the pending pairing.

Poll

The web app polls the pairing list. active means that a signer approved the pairing. If the pairing is not in the list, a signer denied it, it expired, or a user removed it.

Claim

The web app claims the credential one time. A second claim gets 410. A claim after the 120-second claim window also gets 410.

A signer can decide an approval one time only. A decision after the approval expires gets 410. At that time, the control plane deletes the expired approval and its pending pairing.

Health

GET /health

Tells that the control plane is up, and gives the list of optional capabilities. This route does not need authentication.

curl "$CONTROL_PLANE_URL/health"

Response 200

{ "status": "healthy", "capabilities": ["pairing-id-v1"] }
FieldTypeDescription
status"healthy"Always "healthy" when the server answers
capabilitiesstring[]The capabilities that the server supports

pairing-id-v1 means that the server supports POST /v1/gateways/:id/pairings/pairing-id-v1. That route returns a pairing ID with the credential. Desktop looks for this capability before it mints a pairing. If the capability is not there, Desktop does not mint a pairing.

The capability name is also in the route path. Thus an old server answers that path with 404. It does not mint a credential that the client cannot identify.

GET /version

Tells which release of the control plane answers. This route does not need authentication.

Response 200

{ "service": "control-plane", "version": "0.5.0", "commit": "<git-commit>" }
FieldTypeDescription
service"control-plane"The service that answers
versionstringThe release version of the service
commitstring or nullThe full Git commit of the build. null when the build does not record it

GET /ready

Tells if the control plane can get to its database. This route does not need authentication.

StatusBodyMeaning
200{ "status": "ready" }The control plane can serve requests
503{ "status": "unavailable" }The database is not available. Try again later

HQ routes

POST /gw/dial-token

Mints a new dial token for the HQ that signed the assertion. Only the HQ calls this route. A browser or a client with an account token does not call it.

Auth: HQ assertion with aud cp-dial-token. No request body.

curl -X POST "$CONTROL_PLANE_URL/gw/dial-token" \
  -H "Authorization: Bearer $HQ_ASSERTION"

Response 200

{ "dialToken": "<base64url-claims>.<base64url-signature>" }

The new token names the account that owns the HQ record. It contains the public key from enrollment. The request cannot change these values. Thus an HQ cannot move to a different account or change its key.

Errors

StatusBodyWhen
401{ "error": "unauthorized" }The header is missing or malformed. Or the gatewayId is unknown, the signature does not agree with the stored key, aud is not cp-dial-token, the assertion expired, or you deleted the HQ

All failures return the same 401. When you delete an HQ, it cannot get new dial tokens.

POST /gw/version

Records the software version of the HQ that signed the assertion. Only the HQ calls this route. A browser or a client with an account token does not call it. Clients read the version as hqVersion in GET /v1/gateways.

Auth: HQ assertion with aud cp-hq-version.

curl -X POST "$CONTROL_PLANE_URL/gw/version" \
  -H "Authorization: Bearer $HQ_ASSERTION" \
  -H "Content-Type: application/json" \
  -d '{ "hqVersion": "0.11.1" }'

Request body

{ "hqVersion": "0.11.1" }
FieldTypeDescription
hqVersionstringRequired. The HQ software version, for example 0.11.1 or 0.12.1-beta.2. Refer to the rules below

The control plane removes white space at the start and the end of hqVersion. Then it applies these rules:

  • The value has the form MAJOR.MINOR.PATCH, with digits only in each part.
  • An optional tag can follow. The tag starts with - or + and contains only A-Z, a-z, 0-9, . and -.
  • The value has a maximum of 32 characters.

Response 204 with no body.

A new report replaces the version from the last report. The control plane does the check of the assertion before the check of the body. Thus a request with a bad assertion and a bad body gets 401.

The dashsquad HQ sends its version one time at each start in relay mode with a control-plane URL. The HQ does not wait for the answer. If the report fails, the HQ writes a warning to its log and continues to start. For example, an older control plane does not have this route and answers 404.

Errors

StatusBodyWhen
400{ "error": "hqVersion must be a semver version" }The body is not a JSON object, hqVersion is missing or is not a string, or the value does not obey the rules above
401{ "error": "unauthorized" }The header is missing or malformed. Or the gatewayId is unknown, the signature does not agree with the stored key, aud is not cp-hq-version, the assertion expired, or you deleted the HQ

HQs

POST /v1/gateways

Enrolls a new HQ. Desktop calls it when you enable Remote access. Desktop chooses the HQ id. The id is a lowercase UUID v4. It becomes the permanent gatewayId and the relay host label (<uuid>.<relay zone>).

Request body

{
  "gatewayId": "3f1c2b84-5d0e-4a7b-9c1e-6b2f8a4d7e10",
  "publicKey": "<base64url-ed25519-public-key>",
  "displayName": "Alice's MacBook"
}
FieldTypeDescription
gatewayIdstringA lowercase UUID v4. It becomes the permanent gatewayId of the HQ
publicKeystringThe raw 32-byte Ed25519 public key of the HQ, base64url. Required
displayNamestringOptional. A name for people to read, up to 64 characters. You can change it later

The old body { "subdomain": "alice-mbp", "publicKey": "..." } still works. In that body, subdomain is the address label, and it becomes the gatewayId.

A label has 1 to 63 characters: lowercase a-z, 0-9 and -. It must not start or end with a hyphen. These labels are reserved: www, api, admin, relay, health, gw, mc, app, control and status. When an account claims a label, no other HQ can get it again. This is also true after you delete the HQ.

The control plane only checks that publicKey is not empty. Dial tokens contain this exact value. Thus the key must be the raw base64url Ed25519 key of the HQ. If not, relay dial-in and POST /gw/dial-token do not work.

Response 200

{
  "gatewayId": "3f1c2b84-5d0e-4a7b-9c1e-6b2f8a4d7e10",
  "subdomain": "3f1c2b84-5d0e-4a7b-9c1e-6b2f8a4d7e10.relay.example.com",
  "dialToken": "<base64url-claims>.<base64url-signature>",
  "displayName": "Alice's MacBook"
}
FieldTypeDescription
gatewayIdstringThe same as the gatewayId (or the label) in the request
subdomainstringThe full relay hostname of the HQ, <gatewayId>.<relay zone>
dialTokenstringThe first dial token of the HQ
displayNamestring or nullThe name for people to read. It is null when no name is set

The account that calls the route owns the HQ. The control plane does not call the relay at enrollment.

A repeat UUID enrollment returns a fresh dial token for the same account and public key. The HQ must be active. Its address and saved display name stay the same. The old label body does not support this retry.

Errors

StatusBodyWhen
400{ "error": "invalid request" }gatewayId is not a lowercase UUID v4, the label is not valid or is reserved, publicKey is missing, or displayName is longer than 64 characters
401{ "error": "unauthorized" }The account bearer is missing or not valid
409{ "error": "gateway exists" }This UUID belongs to a different account or key, or the HQ is revoked
409{ "error": "subdomain taken" }An account claimed the label before (old body only)

GET /v1/gateways

Gives the list of HQs that the account owns, with deleted HQs. Desktop, the iOS app and the web app use it to show your HQs. Before the iOS app connects, it compares the identity that the relay verified with the publicKey in this list.

Response 200

{
  "gateways": [
    {
      "gatewayId": "3f1c2b84-5d0e-4a7b-9c1e-6b2f8a4d7e10",
      "subdomain": "3f1c2b84-5d0e-4a7b-9c1e-6b2f8a4d7e10.relay.example.com",
      "status": "active",
      "createdAt": 1790208000000,
      "publicKey": "<base64url-ed25519-public-key>",
      "displayName": "Alice's MacBook",
      "hqVersion": "0.11.1"
    }
  ]
}
FieldTypeDescription
gatewayIdstringThe ID of the HQ: a UUID, or the address label of an old HQ
subdomainstringThe full relay hostname
status"active" or "revoked"revoked after DELETE /v1/gateways/:id
createdAtnumberEnrollment time, unix ms
publicKeystringThe registered Ed25519 public key of the HQ
displayNamestring or nullThe name for people to read. It is null when no name is set
hqVersionstring or nullThe HQ software version from the last version report of the HQ, for example 0.11.1. It is null until the HQ reports its version. The HQ reports it each time it starts
onlinebooleanOptional. true means an open relay tunnel. false means a check confirmed no open tunnel

An absent online means that tunnel presence is unknown. A failed or unsupported lookup can omit this field. Agent and provider readiness are separate.

The response does not contain the account or the registered web chat token. An account with no HQs gets { "gateways": [] }.

PATCH /v1/gateways/:id

Renames an HQ. The HQ must belong to the account of the caller. Desktop calls it when you change the name of the HQ in the setup wizard or in the HQ card. It changes the displayName. The gatewayId and the hostname do not change.

The displayName key is required. A string sets the name (the control plane trims it). A blank string or null clears the name.

Request body

{ "displayName": "Office Mac mini" }

Response 200

{ "gatewayId": "3f1c2b84-5d0e-4a7b-9c1e-6b2f8a4d7e10", "displayName": "Office Mac mini" }

Errors

StatusBodyWhen
400{ "error": "invalid request" }The body is not a JSON object, displayName is missing, displayName is not a string or null, or the trimmed name is longer than 64 characters
401{ "error": "unauthorized" }The account bearer is missing or not valid
404{ "error": "HQ not found" }The account has no HQ with this id

GET /v1/subdomains/:label

Tells if you can claim an address label. Only legacy clients call it. Current Desktop does not call it, because the HQ id is a UUID that Desktop picks.

ParameterInDescription
labelpathThe label to check

Response 200

{ "available": true }

available is true only when the label is valid, is not reserved, and nobody claimed it before. A label that is not valid returns { "available": false }, not an error. The check applies to all accounts.

PUT /v1/gateways/:id/web-chat-token

Registers the chat bearer token that browser pairings for this HQ get as chatToken. Desktop sends it when it enrolls the HQ. Desktop sends it again each time it refreshes the enrollment. The latest value replaces the earlier value.

Request body

{ "chatToken": "<hq-phone-scoped-bearer>" }
FieldTypeDescription
chatTokenstringThe phone bearer token of the HQ. Required, a maximum of 4096 characters

Response 200

{ "ok": true }

GET /v1/gateways does not return the token. Only the pairing and claim responses return it.

Errors

StatusBodyWhen
400{ "error": "chatToken required" }chatToken is missing, empty or not a string
400{ "error": "chatToken too long" }chatToken has more than 4096 characters
401{ "error": "unauthorized" }The account bearer is missing or not valid
404{ "error": "HQ not found" }The HQ is unknown, or a different account owns it

DELETE /v1/gateways/:id

Deletes an HQ. The control plane first tells the relay to revoke the HQ and close its live tunnel. Then it keeps the record with status: "revoked". It does not release the label. If the relay call fails, the request fails and the HQ stays active, so you can send the request again. After deletion, the HQ cannot get a new dial token.

Response 200

{ "ok": true }

Errors

StatusBodyWhen
401{ "error": "unauthorized" }The account bearer is missing or not valid
404{ "error": "HQ not found" }The HQ is unknown, or a different account owns it

A request from a different account changes nothing and does not get to the relay.

Pairings

POST /v1/gateways/:id/pairings/pairing-id-v1

Mints a device pairing for one of your HQs. These clients call it:

  • Desktop, for the Android Pair Device QR code.
  • The iOS app, with clientKind: "mobile", when you select an HQ.
  • The web app, with clientKind: "web".
ParameterInDescription
idpathThe gatewayId of the HQ

Request body (optional. The route accepts an empty body or no body.)

{ "deviceLabel": "Alice's iPhone", "clientKind": "mobile", "deviceId": "8F3A2C10-6B1E-4C2D-9E7F-0A1B2C3D4E5F" }
FieldTypeDescription
deviceLabelstringOptional name in device lists. The route ignores a value that is not a string
clientKind"mobile" or "web"Optional. The default is mobile. A different value causes 400
deviceIdstringOptional. A stable ID of the device, 1 to 128 characters after the control plane removes the spaces at the start and end. A different value causes 400

Signer proof headers

A pairing mint can carry these optional headers on either pairing route:

HeaderValue
x-dash-signer-idAn active signer ID from the same account
x-dash-signer-iatUnix seconds, within 60 seconds of the server clock
x-dash-signer-proofAn Ed25519 signature in unpadded base64url

The signature covers this UTF-8 text, with newline characters between values and no final newline:

pairing-mint
<signerId>
<gatewayId>
<iat>

A proof works once for one HQ. A missing, malformed, expired, or reused proof follows the rule for a request without proof. In observe mode, browser pairings still need approval when the account has an active signer.

Response 200, pairing without approval

The route returns this shape when the device-trust rule permits an immediate credential. Refer to Concepts.

{
  "credential": "<relay-device-credential>",
  "pairingId": "pr-3f9c2a...",
  "chatToken": "<hq-phone-scoped-bearer>",
  "status": "active"
}
FieldTypeDescription
credentialstringThe relay credential of the device. The route returns it this one time only
pairingIdstringAn ID that is not secret. Use it to find and revoke this device
chatTokenstringThe registered web chat token of the HQ. Always present for web. Present for mobile only when the HQ has a registered token
status"active"Always active in this shape

The control plane tells the relay to add the credential. It keeps only the SHA-256 hash of the credential, and it records the pairing.

If the request has a deviceId, the control plane then revokes the older active pairings of that device on this HQ. If one of these revokes fails, the control plane writes a warning in its log and returns the new credential. The next mint tries the revoke again. A pending pairing replaces older pairings only after approval. A denied pairing replaces nothing.

Response 200, pairing that needs approval

The route returns this shape when the device-trust rule requires approval. The control plane does not add a credential or return a secret.

{
  "pairingId": "pr-3f9c2a...",
  "status": "pending",
  "approvalId": "k1Xz...",
  "approvalExpiresAt": 1790208120000
}
FieldTypeDescription
pairingIdstringThe pending pairing. It shows in the pairing list with status: "pending"
status"pending"Identifies this shape
approvalIdstringThe approval that a signer must decide
approvalExpiresAtnumberThe approval deadline, unix ms (120 seconds after the mint)

Then continue with the browser approval flow.

Errors

StatusBodyWhen
400{ "error": "invalid clientKind" }clientKind is present, but it is not mobile or web
400{ "error": "invalid deviceId" }deviceId is present, but it is not a string of 1 to 128 characters
401{ "error": "unauthorized" }The account bearer is missing or not valid
404{ "error": "HQ not found" }The HQ is unknown, deleted, or a different account owns it. All other mint failures also return this status, for example a relay error
409{ "error": "no web chat token registered for this HQ" }clientKind is "web", and Desktop did not register a web chat token. The control plane mints nothing. Enroll the HQ from Desktop again

POST /v1/gateways/:id/pairings

The first pairing route. It stays for old Desktop releases. It takes the same body, needs the same signer approval and returns the same errors as the pairing-id-v1 route. For a pairing without approval, it returns only the credential, and chatToken when the HQ has a registered token. It does not return pairingId or status:

{ "credential": "<relay-device-credential>" }
{ "credential": "<relay-device-credential>", "chatToken": "<hq-phone-scoped-bearer>" }

For a pairing that needs approval, it returns the same pending shape as the pairing-id-v1 route. The control plane also records a pairing ID. Thus the device shows in the pairing list, and you can revoke it. New clients must use the pairing-id-v1 route.

GET /v1/gateways/:id/pairings

Gives the list of all pairings for one of your HQs. Desktop shows them under Paired devices. The web app polls this list during browser approval.

Response 200

{
  "pairings": [
    {
      "id": "pr-3f9c2a...",
      "deviceLabel": "iPhone",
      "clientKind": "mobile",
      "status": "active",
      "createdAt": 1790208000000,
      "deviceId": "8F3A2C10-6B1E-4C2D-9E7F-0A1B2C3D4E5F"
    }
  ]
}
FieldTypeDescription
idstringThe pairing ID
deviceLabelstring or nullThe label from the mint request
clientKind"mobile" or "web"The device class
status"active", "pending" or "revoked"Refer to Concepts
createdAtnumberMint time, unix ms
deviceIdstringOnly on a pairing that a mint request with a deviceId made

The list starts with the newest pairing. Revoked pairings stay in the list. Thus your client must filter them or show their status. The control plane deletes denied and expired pending pairings, so they are not in the list. The response does not contain credential hashes.

Errors

StatusBodyWhen
401{ "error": "unauthorized" }The account bearer is missing or not valid
404{ "error": "HQ not found" }The HQ is unknown, or a different account owns it

An HQ that you own and that has no pairings returns { "pairings": [] }.

DELETE /v1/gateways/:id/pairings/:pid

Revokes one device. These controls call it: Revoke in Desktop, Disconnect & Forget in the iOS app, and the device controls in the web app.

ParameterInDescription
idpathThe gatewayId of the HQ
pidpathThe pairing ID

Response 200

{ "ok": true }
  • Active pairing: The control plane marks it revoked. It then tells the relay to remove that one credential by its hash. The other devices continue to work.
  • Pending pairing: The control plane deletes it. It does not call the relay, because no credential exists.

Errors

StatusBodyWhen
401{ "error": "unauthorized" }The account bearer is missing or not valid
404{ "error": "pairing not found" }The pairing or the HQ is unknown, or a different account owns the HQ

POST /v1/gateways/:id/pairings/:pid/credential

Claims the credential and the chat token that the control plane keeps for an approved device pairing. Desktop and the web app call it. You can claim the credential one time, in the 120 seconds after approval. No request body.

Response 200

{ "credential": "<relay-device-credential>", "chatToken": "<hq-phone-scoped-bearer>" }

The response has no chatToken if the HQ had no registered token at approval.

Other responses

StatusBodyWhen
401{ "error": "unauthorized" }The account bearer is missing or not valid
404{ "error": "pairing not found" }The pairing is unknown (also when a signer denied it or it expired). Or the HQ is unknown or deleted, or a different account owns it
409{ "status": "pending" }No signer decided yet. Poll again
410{ "error": "credential already claimed" }A client claimed it before, the claim window ended, you revoked the pairing, or the pairing did not need approval

Signers

POST /v1/signers

Registers the Ed25519 public key of the device as a signer for the account. The iOS app registers before it approves its first browser pairing.

Request body

{ "publicKey": "<base64url-ed25519-public-key>", "label": "Alice's iPhone" }
FieldTypeDescription
publicKeystringA raw 32-byte Ed25519 public key in canonical base64url without padding
labelstringThe display name. If it is missing, the control plane keeps an empty string

Response 201

{ "signerId": "sg-1a2b3c4d5e6f" }

signerId is sg- and 12 hex characters. Registration is idempotent for each account and key. If you register the same key again, you get the same signerId, and the control plane changes its label.

In observe mode, each registration becomes active immediately. In enforce mode, the first signer becomes active immediately. Each later new key needs approval from an active signer.

Response 202, new signer that needs approval

{
  "status": "pending",
  "signerId": "sg-1a2b3c4d5e6f",
  "approvalId": "k1Xz...",
  "approvalExpiresAt": 1790208120000
}

The approval expires after 120 seconds. Register the same key again to check its status. An open approval keeps its ID. After expiry, registration creates a new approval. After approval, registration returns 201.

There is no route to remove a signer.

Errors

StatusBodyWhen
400{ "error": "invalid public key" }The key is not exactly 32 bytes, is not base64url, or is not canonical (for example, it has = padding)
401{ "error": "unauthorized" }The account bearer is missing or not valid

GET /v1/signers

Gives the list of signers of the account.

Response 200

{
  "signers": [
    { "signerId": "sg-1a2b3c4d5e6f", "label": "Alice's iPhone", "createdAt": 1790208000000, "status": "active" }
  ]
}

Each signer has status: "active" or status: "pending". The response does not contain public keys. An account with no signers gets { "signers": [] }.

Approvals

GET /v1/approvals/:id

Gets an approval, so that a signer can show what it approves. The iOS app calls it after it scans a dash-approve:v1:<approvalId> QR code.

Response 200

{
  "approvalId": "k1Xz...",
  "kind": "pairing",
  "pairingId": "pr-3f9c2a...",
  "gatewayId": "3f1c2b84-5d0e-4a7b-9c1e-6b2f8a4d7e10",
  "deviceLabel": "Safari",
  "expiresAt": 1790208120000
}
FieldTypeDescription
approvalIdstringThe approval ID
pairingIdstringThe pending pairing that approval makes active
gatewayIdstringThe HQ that the browser wants to connect to
deviceLabelstring or nullThe label from the browser
expiresAtnumberThe deadline, unix ms. The same value as approvalExpiresAt from the mint

For a signer approval, the response has kind: "signer" and signerId instead of pairingId and gatewayId. Its deviceLabel is the new signer's label. It does not contain a public key.

The response has no status field. The route also returns the approval after a decision or after expiry. A signer learns that it is too late from the 410 of the decision route.

Errors

StatusBodyWhen
401{ "error": "unauthorized" }The account bearer is missing or not valid
404{ "error": "approval not found" }The approval is unknown, or a different account owns it

POST /v1/approvals/:id/decision

Records the approve or deny decision of a signer.

Request body

{ "decision": "approve", "signerId": "sg-1a2b3c4d5e6f", "signature": "<base64url-ed25519-signature>" }
FieldTypeDescription
decision"approve" or "deny"The decision
signerIdstringA signer of the same account
signaturestringAn Ed25519 signature, base64url, by the key of that signer over the message below

The signed message is these three values, with a newline character between them, in UTF-8. There is no newline at the end:

<approvalId>
<subjectId>
<decision>

subjectId is pairingId for a pairing approval or signerId for a signer approval. decision is the literal approve or deny. An active signer of the account must sign the decision.

Response 204 with an empty body, for approve and for deny. For a pairing approval, poll the pairing list or the claim route. For a signer approval, repeat registration or call GET /v1/signers.

The control plane checks the request in this sequence:

  1. The approval exists, and the account of the caller owns it. If not, 404.
  2. The approval is pending and not expired. If not, 410. If it expired while pending, the control plane marks it denied and deletes the pending pairing or signer.
  3. The signer is active on the account, and the signature is correct. If not, 403. A failed request does not use the approval. Thus the real signer can still decide it.
  4. The approval changes to approved or denied in one atomic step. This step checks the expiry again. If a different decision or the deadline comes first, the result is 410.

For a pairing approval, approve adds the credential on the relay and marks the pairing active. It keeps the credential and the current web chat token for one claim in the next 120 seconds. If a user removed the pairing during the decision, the control plane revokes the new credential on the relay immediately. The response is then 410.

On deny, the control plane deletes the pending pairing. For a signer approval, approve activates the new signer. Deny deletes the pending signer.

Errors

StatusBodyWhen
400{ "error": "invalid request" }decision is not approve or deny, or signerId or signature is missing
401{ "error": "unauthorized" }The account bearer is missing or not valid
403{ "error": "invalid signature" }The signer is unknown or pending for this account, or the signature is not correct
404{ "error": "approval not found" }The approval is unknown, or a different account owns it
410{ "error": "approval expired or already decided" }The approval expired, a signer decided it before, the pairing is not pending, or you deleted the HQ

Usage data

These routes carry the events and the setting of Usage data. The web app, Desktop and the iOS app call them with the account bearer. A request body is a maximum of 65,536 bytes. A larger body gets 413 with { "error": "payload too large" }.

POST /v1/events

Sends a batch of usage events.

Request body

{
  "platform": "web",
  "channel": "production",
  "app_version": "1.4.0",
  "events": [
    { "event": "screen_viewed", "properties": { "screen": "chat" }, "timestamp": "2026-10-11T08:00:00.000Z" }
  ]
}
FieldTypeDescription
platform"web", "desktop" or "ios"The app that sends the batch
channel"dev", "staging" or "production"The release channel of the app
app_versionstringThe app version, for example 1.4.0. A maximum of 32 characters
eventsobject[]A maximum of 50 events
events[].eventstringAn event name from the fixed list of the control plane
events[].propertiesobjectOptional. Only the properties and values that the list permits for that event
events[].timestampstringOptional. A UTC time, for example 2026-10-11T08:00:00.000Z

Response 202

{ "accepted": 1, "dropped": 0 }

The control plane checks each event separately. It drops an event with an unknown name, an unknown property, a value that is not permitted, or a bad timestamp. It keeps the other events of the batch. If the account turned off usage data, the control plane drops all events. If the server sends no usage data, the response is { "accepted": 0, "dropped": 0 }.

Errors

StatusBodyWhen
400{ "error": "invalid json" }The body is not JSON
400{ "error": "invalid body" }, "invalid platform", "invalid channel", "invalid app_version" or "invalid events"A field of the batch is missing or not correct, or events has more than 50 items
401{ "error": "unauthorized" }The account bearer is missing or not valid
413{ "error": "payload too large" }The body has more than 65,536 bytes

GET /v1/flags

Gives the feature flags of the account.

Response 200

{ "flags": { "example-flag": true, "example-variant": "b" } }

Each value is a boolean or a string. If the server sends no usage data, flags is an empty object.

GET /v1/analytics

Gives the usage data setting of the account.

Response 200

{ "enabled": true, "available": true }
FieldTypeDescription
enabledbooleantrue when the account sends usage data. Always false when available is false
availablebooleantrue when this server sends usage data. If it is false, do not show the setting

PUT /v1/analytics

Turns usage data on or off for the full account.

Request body

{ "enabled": false }

Response 200 with the same fields as GET /v1/analytics.

Errors

StatusBodyWhen
400{ "error": "enabled must be a boolean" }enabled is missing or is not a boolean
401{ "error": "unauthorized" }The account bearer is missing or not valid

Browser access (CORS)

The web app calls the control plane from a different origin. Thus /v1/* supports CORS for a list of permitted origins. An origin must agree exactly. There are no wildcards and no subdomain matches. The control plane does not permit credentials (cookies), because authentication uses a bearer header.

For a permitted origin, the control plane accepts the methods GET, POST, PUT, PATCH, DELETE and OPTIONS, and the headers Authorization and Content-Type. It answers preflight requests before authentication. Other origins get no CORS headers.

/health, /version, /ready, /gw/dial-token and /gw/version do not send CORS headers. Only HQs use the /gw/* routes.

On this page