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:
| Client | Source of the base URL |
|---|---|
| Desktop | Its control-plane setting. Desktop uses it after you sign in, in the setup wizard or in the Account card of Settings → General |
| iOS app | The DashControlPlaneURL value in the app |
| web app | The controlPlaneUrl value in its /config.js file. If that value is empty, the VITE_CONTROL_PLANE_URL build setting |
| HQ | The --control-plane-url flag |
Authentication
The control plane has three authentication schemes, one for each route group.
| Routes | Scheme | Header |
|---|---|---|
GET /health, GET /version, GET /ready | None | None |
All /v1/* routes | dashsquad account sign-in (Clerk ID token or session token) | Authorization: Bearer <token> |
POST /gw/dial-token, POST /gw/version | An assertion that the HQ signs with its private key | Authorization: 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:
| Token | Sent by | Other checks | Organization |
|---|---|---|---|
| OIDC ID token | iOS app, old Desktop releases | The audience (aud) must be the sign-in application | org_id |
| Session token, version 2 | Desktop, web app | The 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:
| Claim | Type | Description |
|---|---|---|
gatewayId | string | The ID of the HQ: a UUID, or the address label of an old HQ |
aud | string | Must be exactly the audience of the route: cp-dial-token for POST /gw/dial-token, cp-hq-version for POST /gw/version |
iat | number | Time of issue, unix seconds |
exp | number | Expiry, 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
| Method | Path | Auth | Called by |
|---|---|---|---|
GET | /health | None | Desktop |
GET | /version | None | No dashsquad client at this time |
GET | /ready | None | No dashsquad client at this time |
POST | /gw/dial-token | HQ assertion | HQ |
POST | /gw/version | HQ assertion | HQ |
POST | /v1/gateways | Account | Desktop |
GET | /v1/gateways | Account | Desktop, iOS app, web app |
PATCH | /v1/gateways/:id | Account | Desktop |
GET | /v1/subdomains/:label | Account | Old Desktop releases |
PUT | /v1/gateways/:id/web-chat-token | Account | Desktop |
DELETE | /v1/gateways/:id | Account | No dashsquad client at this time |
POST | /v1/gateways/:id/pairings/pairing-id-v1 | Account | Desktop, iOS app, web app |
POST | /v1/gateways/:id/pairings | Account | Old Desktop releases |
GET | /v1/gateways/:id/pairings | Account | Desktop, web app |
DELETE | /v1/gateways/:id/pairings/:pid | Account | Desktop, iOS app, web app |
POST | /v1/gateways/:id/pairings/:pid/credential | Account | web app |
POST | /v1/signers | Account | iOS app |
GET | /v1/signers | Account | Desktop |
GET | /v1/approvals/:id | Account | iOS app |
POST | /v1/approvals/:id/decision | Account | iOS app |
POST | /v1/events | Account | Desktop, iOS app, web app |
GET | /v1/flags | Account | Desktop, iOS app, web app |
GET | /v1/analytics | Account | Desktop, iOS app, web app |
PUT | /v1/analytics | Account | Desktop, 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:
| Status | Meaning |
|---|---|
active | The credential is live on the relay |
pending | A device pairing waits for the approval of an active signer. No credential exists yet |
revoked | The 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:
| Mode | Account has no active signer | Account has an active signer |
|---|---|---|
observe | All pairings return a credential immediately | Browser pairings need approval. Mobile pairings return a credential immediately |
enforce | All pairings return a credential immediately | All 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:
- Android: Desktop calls
POST /v1/gateways/:id/pairings/pairing-id-v1. Desktop puts the credential in the Pair Device QR code. - iOS: After sign-in, the app gets the list of HQs with
GET /v1/gateways. The app then makes its own pairing withclientKind: "mobile". The response has the credential. It also haschatTokenif the HQ has a registered web chat token. - 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 andchatTokenfor 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"] }| Field | Type | Description |
|---|---|---|
status | "healthy" | Always "healthy" when the server answers |
capabilities | string[] | 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>" }| Field | Type | Description |
|---|---|---|
service | "control-plane" | The service that answers |
version | string | The release version of the service |
commit | string or null | The 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.
| Status | Body | Meaning |
|---|---|---|
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
| Status | Body | When |
|---|---|---|
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" }| Field | Type | Description |
|---|---|---|
hqVersion | string | Required. 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 onlyA-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
| Status | Body | When |
|---|---|---|
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"
}| Field | Type | Description |
|---|---|---|
gatewayId | string | A lowercase UUID v4. It becomes the permanent gatewayId of the HQ |
publicKey | string | The raw 32-byte Ed25519 public key of the HQ, base64url. Required |
displayName | string | Optional. 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"
}| Field | Type | Description |
|---|---|---|
gatewayId | string | The same as the gatewayId (or the label) in the request |
subdomain | string | The full relay hostname of the HQ, <gatewayId>.<relay zone> |
dialToken | string | The first dial token of the HQ |
displayName | string or null | The 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
| Status | Body | When |
|---|---|---|
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"
}
]
}| Field | Type | Description |
|---|---|---|
gatewayId | string | The ID of the HQ: a UUID, or the address label of an old HQ |
subdomain | string | The full relay hostname |
status | "active" or "revoked" | revoked after DELETE /v1/gateways/:id |
createdAt | number | Enrollment time, unix ms |
publicKey | string | The registered Ed25519 public key of the HQ |
displayName | string or null | The name for people to read. It is null when no name is set |
hqVersion | string or null | The 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 |
online | boolean | Optional. 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
| Status | Body | When |
|---|---|---|
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.
| Parameter | In | Description |
|---|---|---|
label | path | The 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>" }| Field | Type | Description |
|---|---|---|
chatToken | string | The 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
| Status | Body | When |
|---|---|---|
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
| Status | Body | When |
|---|---|---|
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".
| Parameter | In | Description |
|---|---|---|
id | path | The 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" }| Field | Type | Description |
|---|---|---|
deviceLabel | string | Optional 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 |
deviceId | string | Optional. 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:
| Header | Value |
|---|---|
x-dash-signer-id | An active signer ID from the same account |
x-dash-signer-iat | Unix seconds, within 60 seconds of the server clock |
x-dash-signer-proof | An 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"
}| Field | Type | Description |
|---|---|---|
credential | string | The relay credential of the device. The route returns it this one time only |
pairingId | string | An ID that is not secret. Use it to find and revoke this device |
chatToken | string | The 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
}| Field | Type | Description |
|---|---|---|
pairingId | string | The pending pairing. It shows in the pairing list with status: "pending" |
status | "pending" | Identifies this shape |
approvalId | string | The approval that a signer must decide |
approvalExpiresAt | number | The approval deadline, unix ms (120 seconds after the mint) |
Then continue with the browser approval flow.
Errors
| Status | Body | When |
|---|---|---|
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"
}
]
}| Field | Type | Description |
|---|---|---|
id | string | The pairing ID |
deviceLabel | string or null | The label from the mint request |
clientKind | "mobile" or "web" | The device class |
status | "active", "pending" or "revoked" | Refer to Concepts |
createdAt | number | Mint time, unix ms |
deviceId | string | Only 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
| Status | Body | When |
|---|---|---|
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.
| Parameter | In | Description |
|---|---|---|
id | path | The gatewayId of the HQ |
pid | path | The 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
| Status | Body | When |
|---|---|---|
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
| Status | Body | When |
|---|---|---|
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" }| Field | Type | Description |
|---|---|---|
publicKey | string | A raw 32-byte Ed25519 public key in canonical base64url without padding |
label | string | The 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
| Status | Body | When |
|---|---|---|
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
}| Field | Type | Description |
|---|---|---|
approvalId | string | The approval ID |
pairingId | string | The pending pairing that approval makes active |
gatewayId | string | The HQ that the browser wants to connect to |
deviceLabel | string or null | The label from the browser |
expiresAt | number | The 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
| Status | Body | When |
|---|---|---|
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>" }| Field | Type | Description |
|---|---|---|
decision | "approve" or "deny" | The decision |
signerId | string | A signer of the same account |
signature | string | An 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:
- The approval exists, and the account of the caller owns it. If not,
404. - 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. - 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. - 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
| Status | Body | When |
|---|---|---|
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" }
]
}| Field | Type | Description |
|---|---|---|
platform | "web", "desktop" or "ios" | The app that sends the batch |
channel | "dev", "staging" or "production" | The release channel of the app |
app_version | string | The app version, for example 1.4.0. A maximum of 32 characters |
events | object[] | A maximum of 50 events |
events[].event | string | An event name from the fixed list of the control plane |
events[].properties | object | Optional. Only the properties and values that the list permits for that event |
events[].timestamp | string | Optional. 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
| Status | Body | When |
|---|---|---|
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 }| Field | Type | Description |
|---|---|---|
enabled | boolean | true when the account sends usage data. Always false when available is false |
available | boolean | true 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
| Status | Body | When |
|---|---|---|
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.