Skip to main content

API Endpoints

Overview​

ControlR's HTTP surface is split into three route roots.

Route rootAudienceStatus
/api/v1/*Service-to-service automation and integrationsSupported
/api/*ControlR's own Blazor front-end (BFF pattern)Deprecated
/api/agent/*Device agentsAgent contract, not for integrators

What to build against​

Build against /api/v1/*. It is the only surface the server treats as a stable contract.

/api/* is the surface the ControlR web app calls. The server keeps it, and it is not the surface to integrate against. Nearly every action on it carries an [ApiDeprecated] attribute naming its V1 twin, and the OpenAPI document for that group marks those operations deprecated: true and adds an x-replacement-route extension. Of the 79 Internal routes, 71 are marked deprecated. The eight that are not are the six /api/auth routes, the form-encoded archive download the Blazor file input uses, and the anonymous invite accept. See Internal endpoints (deprecated).

Two OpenAPI documents are published, v1 and internal, served from /openapi/v1.json and /openapi/internal.json. Both are gated behind AppOptions:EnableScalarUi, which also mounts the Scalar reference UI that lists them.

Conventions used in the V1 tables​

  • Permission is the canonical permission name the server evaluates. It is not a policy name. A principal needs the named permission granted at the scope the row names (Server, Tenant, Device, Device group, or user group).
  • Required parameters lists the parameters the server rejects a request over. Route parameters and tenantId are covered. Optional query parameters are named only where they change the response.
  • tenantId is a query parameter unless the row says it is in the route or in the body. See Tenant scoping.

Authentication​

The server forwards every request to one handler based on what the request carries. It checks in this order:

  1. A logon token, when the path starts with /device-access and the query contains logonToken.
  2. A bearer token, only when AppOptions:EnableInteractiveBearerLogin is set.
  3. The x-personal-token header.
  4. The x-api-key header.
  5. Otherwise the Identity application cookie.
SchemeCredentialNotes
Service accountx-api-key: <credentialId>:<secret>Serves both server service accounts and tenant service accounts. A server service account carries no tenant claim and may address any tenant. A tenant service account carries a tenant claim and is confined to it.
Personal access tokenx-personal-token: <token>Acts as the owning user.
Interactive session.AspNetCore.Identity.Application cookieThe web front-end.
Bearer tokenAuthorization: Bearer <token>Accepted only when AppOptions:EnableInteractiveBearerLogin is set. It is off in the shipped appsettings.json.
Logon token?logonToken= on /device-accessDevice access links, not an API credential.
AnonymousnoneMarked per route below. Agent registration authenticates with installer-key credentials in the request body.

The OpenAPI documents advertise the cookie, x-personal-token, and x-api-key schemes. The internal document does not attach x-api-key to any operation, although its scheme definition is still listed there and the dynamic scheme selector accepts that header on Internal routes.

Authorization​

Authorization is enforced by the fine-grained permission system. Names in the tables are the server's canonical permission identifiers. See the Permission System for the model.

Device-scoped permissions (device.*) are evaluated against the target device, so a grant over one device does not carry to another. Device group and user group permissions (device-group.*, user-group.*) are evaluated against the named group.

Tenant scoping​

Most V1 actions take a Guid tenantId. An action-model convention adds a filter to every V1 action that takes one, and the filter answers 400 with a ProblemDetails body when the value is absent or 00000000-0000-0000-0000-000000000000. Treat tenantId as required on every row that names it.

After that check, the server resolves the tenant against the caller:

  • A server-scoped principal may name any tenant. That means a server service account. A signed-in user is not one of these for this check, even a user holding server-scope permissions.
  • Any other principal must name the tenant in its own claim. A mismatch is answered 403, not 404.

Deprecated Internal routes have no such filter. They derive the tenant from the caller's claim and answer 403 for an unusable one, which is one reason a migrated caller should send the parameter explicitly.

V1 endpoints (supported)​

All V1 routes live under the literal /api/v1/... path. The controllers are declared as API version 1.0, the server assumes that version when a request does not state one, and the path segment is what selects the group. There is no separate version header to send.

31 route groups, 130 routes.

GroupRoutesGroupRoutes
Authorization change logs2Server alert2
Customers6Server logs1
Deployment options2Server service accounts8
Desktop preview1Server stats1
Device file system12Tags5
Device groups7Tenant service accounts8
Device tags2Tenant settings5
Devices8Tenants5
Effective permissions1Test email1
Effective user preferences1User groups7
Installer keys5User preferences4
Invites3User server settings2
Logon tokens2User storage3
Permission assignments10Users8
Personal access tokens4Version3
Public server settings1

Authorization change logs​

/api/v1/authorization-change-logs

MethodPathRequired parametersPermission
GET/api/v1/authorization-change-logstenantId (query)server.authorization-logs.read or tenant.authorization-logs.read
GET/api/v1/authorization-change-logs/servernoneserver.authorization-logs.read

The tenant-addressed list accepts either permission. A holder of server.authorization-logs.read may name any tenant through tenantId. A holder of only tenant.authorization-logs.read sees their own tenant. A search filter is available as query parameters on the AuthorizationChangeLogSearchQueryDto binding.

The /server route lists the audit entries that belong to no tenant, such as server service-account edits. It takes no tenantId because those rows have none.

Customers​

/api/v1/customers

MethodPathRequired parametersPermission
POST/api/v1/customerstenantId (query), bodytenant.customers.write
GET/api/v1/customerstenantId (query)tenant.customers.read
GET/api/v1/customers/{customerId}customerId (route), tenantId (query)tenant.customers.read
PUT/api/v1/customers/{customerId}customerId (route), tenantId (query), bodytenant.customers.write
DELETE/api/v1/customers/{customerId}customerId (route), tenantId (query)tenant.customers.write
POST/api/v1/customers/{customerId}/devicescustomerId (route), tenantId (query), bodytenant.customers.write

Deployment options​

/api/v1/deployment-options

MethodPathRequired parametersPermission
GET/api/v1/deployment-optionstenantId (query)agent.install
POST/api/v1/deployment-options/tag-capabilitytenantId (query), bodyagent.install

Desktop preview​

/api/v1/desktop-preview

MethodPathRequired parametersPermission
GET/api/v1/desktop-preview/{deviceId}/{targetProcessId}deviceId (route), targetProcessId (route, int), tenantId (query)device.desktop-preview.read

Responds with image/jpeg. The route disables the server-side request timeout, so a slow device is not cut off by the server.

Device file system​

/api/v1/device-file-system

MethodPathRequired parametersPermission
POST/api/v1/device-file-system/contentstenantId (query), bodydevice.file-system.read
POST/api/v1/device-file-system/path-segmentstenantId (query), bodydevice.file-system.read
POST/api/v1/device-file-system/root-drivestenantId (query), bodydevice.file-system.read
POST/api/v1/device-file-system/subdirectoriestenantId (query), bodydevice.file-system.read
POST/api/v1/device-file-system/validate-path/{deviceId}deviceId (route), tenantId (query), bodydevice.file-system.read
POST/api/v1/device-file-system/create-directory/{deviceId}deviceId (route), tenantId (query), bodydevice.file-system.write
POST/api/v1/device-file-system/upload/{deviceId}deviceId (route), tenantId (query), multipart bodydevice.file-system.transfer-upload
GET/api/v1/device-file-system/download/{deviceId}deviceId (route), tenantId (query), filePath (query)device.file-system.transfer-download
POST/api/v1/device-file-system/download-archive/{deviceId}deviceId (route), tenantId (query), bodydevice.file-system.transfer-download
DELETE/api/v1/device-file-system/delete-path/{deviceId}deviceId (route), tenantId (query), bodydevice.file-system.delete
GET/api/v1/device-file-system/logs/{deviceId}deviceId (route), tenantId (query)device.logs.read
GET/api/v1/device-file-system/logs/{deviceId}/contentsdeviceId (route), tenantId (query), filePath (query)device.logs.read

The class has no per-action policy. Authentication is required on every route, and the device permission is enforced inside the file-system service for the specific operation, as listed above.

Unlike the deprecated Internal twin, V1 has no /download-archive/{deviceId}/form route. That route exists only for the browser form upload.

Device groups​

/api/v1/device-groups

MethodPathRequired parametersPermission
POST/api/v1/device-groupstenantId (query), bodytenant.device-groups.write
GET/api/v1/device-groupstenantId (query)tenant.device-groups.read
GET/api/v1/device-groups/{deviceGroupId}deviceGroupId (route), tenantId (query)tenant.device-groups.read
PUT/api/v1/device-groups/{deviceGroupId}deviceGroupId (route), tenantId (query), bodytenant.device-groups.write
DELETE/api/v1/device-groups/{deviceGroupId}deviceGroupId (route), tenantId (query)tenant.device-groups.write
POST/api/v1/device-groups/{deviceGroupId}/membersdeviceGroupId (route), tenantId (query), bodydevice-group.assign-devices on that group
DELETE/api/v1/device-groups/{deviceGroupId}/membersdeviceGroupId (route), tenantId (query), bodydevice-group.assign-devices on that group

Device tags​

/api/v1/device-tags

MethodPathRequired parametersPermission
POST/api/v1/device-tagstenantId (query), bodydevice.tags.write on the target device
DELETE/api/v1/device-tags/{deviceId}/{tagId}deviceId (route), tagId (route), tenantId (query)device.tags.write on that device

Devices​

/api/v1/devices

MethodPathRequired parametersPermission
GET/api/v1/devicesnoneDevice access scope
GET/api/v1/devices/summarynoneDevice access scope
POST/api/v1/devices/searchbodyDevice access scope
GET/api/v1/devices/{deviceId}deviceId (route)device.read
GET/api/v1/devices/{deviceId}/desktop-sessionsdeviceId (route)device.read, plus per-session desktop access
PATCH/api/v1/devices/{deviceId}/aliasdeviceId (route), bodydevice.alias.write
DELETE/api/v1/devices/{deviceId}deviceId (route)device.delete
POST/api/v1/devices/delete-manybody, DeviceIds requireddevice.delete per device

No route in this group takes tenantId. These routes resolve the caller's device access scope, so a server-scoped principal sees across tenants and a tenant principal sees its own.

GET /api/v1/devices and GET /api/v1/devices/summary stream the result as a sequence rather than returning one envelope object.

POST /api/v1/devices/delete-many answers with both the deleted ids and the ids it refused, so check the response body instead of the status code alone.

Effective permissions​

/api/v1/effective-permissions

MethodPathRequired parametersPermission
GET/api/v1/effective-permissions/{principalId}principalId (route), tenantId (query), principalKind (query), permissionName (query), scopeKind (query), scopeId (query, optional)tenant.permissions.read

principalKind must be one of User, UserGroup, or ServiceAccount. Anything else is answered 400. permissionName is required and capped at 150 characters. ServiceAccount covers tenant service accounts only, and a principal in another tenant is answered 404, the same answer as a principal that does not exist.

There is no POST /api/v1/effective-permissions/query.

Effective user preferences​

/api/v1/effective-user-preferences

MethodPathRequired parametersPermission
GET/api/v1/effective-user-preferencestenantId (query)Any authenticated principal

Returns the effective value of each preference the calling user can hold, together with a flag saying whether the tenant enforces that preference. The current set is NotifyUserOnSessionStart and IsNotifyUserOnSessionStartTenantEnforced.

Installer keys​

/api/v1/installer-keys

MethodPathRequired parametersPermission
POST/api/v1/installer-keysbody, TenantId requiredinstaller-key.write
GET/api/v1/installer-keystenantId (query)installer-key.read
GET/api/v1/installer-keys/{keyId}/usageskeyId (route), tenantId (query)installer-key.read
PUT/api/v1/installer-keys/{keyId}keyId (route), tenantId (query), bodyinstaller-key.write
DELETE/api/v1/installer-keys/{keyId}keyId (route), tenantId (query)installer-key.write

POST is the only route that returns the key secret, and it returns it once. PUT renames a key and answers 204. Usage rows are recorded by the server when an agent installs with a key, and there is no endpoint that increments them.

Invites​

/api/v1/invites

MethodPathRequired parametersPermission
POST/api/v1/invitestenantId (query), bodytenant.users.write
GET/api/v1/invitestenantId (query)tenant.users.read
DELETE/api/v1/invites/{inviteId}inviteId (route), tenantId (query)tenant.users.write

Accepting an invite is not part of V1. The anonymous accept route is on the deprecated surface, described below.

Logon tokens​

/api/v1/logon-tokens

MethodPathRequired parametersPermission
POST/api/v1/logon-tokens/userbody, includes DeviceId, TenantId, UserIddevice.logon-token.create on the device
POST/api/v1/logon-tokens/externalbody, includes DeviceId, TenantId, UserCorrelationId (required)device.logon-token.create on the device

Both routes carry the tenant in the request body rather than the query string. The external route creates a transient user identity from a correlation id you supply, which suits an integration that has its own account system. Both accept optional Permissions, AllowedDesktopSessionIds, SessionCorrelationId, and ExpirationMinutes.

Permission assignments​

/api/v1/permission-assignments

MethodPathRequired parametersPermission
GET/api/v1/permission-assignmentstenantId (query), principalKind (query), principalId (query)tenant.permissions.read
GET/api/v1/permission-assignments/catalogtenantId (query)tenant.permissions.read
GET/api/v1/permission-assignments/presetstenantId (query)tenant.permissions.read
POST/api/v1/permission-assignmentstenantId (query), bodytenant.permissions.write
POST/api/v1/permission-assignments/batchtenantId (query), body, Assignments required and non-emptytenant.permissions.write
POST/api/v1/permission-assignments/batch-deletetenantId (query), body, AssignmentIds requiredtenant.permissions.write
POST/api/v1/permission-assignments/presets/applytenantId (query), bodytenant.permissions.write
POST/api/v1/permission-assignments/replacetenantId (query), bodytenant.permissions.write
PUT/api/v1/permission-assignments/{assignmentId}assignmentId (route), tenantId (query), bodytenant.permissions.write
DELETE/api/v1/permission-assignments/{assignmentId}assignmentId (route), tenantId (query)tenant.permissions.write

The batch routes are batch and batch-delete. There is no create-many or delete-many route on this group.

Personal access tokens​

/api/v1/personal-access-tokens

MethodPathRequired parametersPermission
POST/api/v1/personal-access-tokenstenantId (query), bodypersonal-access-token.self.write
GET/api/v1/personal-access-tokenstenantId (query)personal-access-token.self.read
PUT/api/v1/personal-access-tokens/{id}id (route), tenantId (query), bodypersonal-access-token.self.write
DELETE/api/v1/personal-access-tokens/{id}id (route), tenantId (query)personal-access-token.self.write

These are self-service routes for the calling user's own tokens. Managing another user's tokens is under Users. POST answers 201 and DELETE answers 204.

Public server settings​

/api/v1/public-server-settings

MethodPathRequired parametersPermission
GET/api/v1/public-server-settingsnoneAnonymous

Returns IsPublicRegistrationEnabled and DisableDesktopPreview. The response is cached for 60 seconds.

Server alert​

/api/v1/server-alert

MethodPathRequired parametersPermission
GET/api/v1/server-alertnoneAny authenticated principal
POST/api/v1/server-alertbodyserver.settings.write

Server logs​

/api/v1/server-logs

MethodPathRequired parametersPermission
GET/api/v1/server-logs/get-aspire-urlnoneserver.telemetry.read

Reports whether the Aspire dashboard is configured, and returns its URL with an auth token.

Server service accounts​

/api/v1/server-service-accounts

MethodPathRequired parametersPermission
POST/api/v1/server-service-accountsbodyserver.service-accounts.write
GET/api/v1/server-service-accountsnoneserver.service-accounts.read
GET/api/v1/server-service-accounts/{serviceAccountId}serviceAccountId (route)server.service-accounts.read
PUT/api/v1/server-service-accounts/{serviceAccountId}serviceAccountId (route), bodyserver.service-accounts.write
DELETE/api/v1/server-service-accounts/{serviceAccountId}serviceAccountId (route)server.service-accounts.write
POST/api/v1/server-service-accounts/{serviceAccountId}/credentialsserviceAccountId (route), bodyserver.service-accounts.rotate-credentials
DELETE/api/v1/server-service-accounts/{serviceAccountId}/credentials/{credentialId}route idsserver.service-accounts.rotate-credentials
DELETE/api/v1/server-service-accounts/{serviceAccountId}/credentials/{credentialId}/purgeroute idsserver.service-accounts.rotate-credentials

No route here takes tenantId. A server service account belongs to no tenant. POST .../credentials returns the credential secret once. DELETE .../credentials/{credentialId} revokes it and keeps the record, and the purge route removes the record.

Server stats​

/api/v1/server-stats

MethodPathRequired parametersPermission
GET/api/v1/server-statsnoneserver.telemetry.read

Tags​

/api/v1/tags

MethodPathRequired parametersPermission
GET/api/v1/tagstenantId (query)Any authenticated principal, filtered by device access scope
GET/api/v1/tags/{tagId}tagId (route), tenantId (query)Any authenticated principal, filtered by device access scope
POST/api/v1/tagstenantId (query), bodytenant.tags.write
PUT/api/v1/tags/{tagId}tagId (route), tenantId (query), bodytenant.tags.write
DELETE/api/v1/tags/{tagId}tagId (route), tenantId (query)tenant.tags.write

GET /api/v1/tags takes an optional includeLinkedIds query boolean, which adds the device ids linked to each tag. Renaming is PUT against the tag id, and V1 has no /rename route.

Tenant service accounts​

/api/v1/tenant-service-accounts/{tenantId}

The tenant id is part of the route on every route in this group.

MethodPathRequired parametersPermission
POST/api/v1/tenant-service-accounts/{tenantId}tenantId (route), bodyservice-account.write
GET/api/v1/tenant-service-accounts/{tenantId}tenantId (route)service-account.read
GET/api/v1/tenant-service-accounts/{tenantId}/{serviceAccountId}route idsservice-account.read
PUT/api/v1/tenant-service-accounts/{tenantId}/{serviceAccountId}route ids, bodyservice-account.write
DELETE/api/v1/tenant-service-accounts/{tenantId}/{serviceAccountId}route idsservice-account.write
POST/api/v1/tenant-service-accounts/{tenantId}/{serviceAccountId}/credentialsroute ids, bodyservice-account.rotate-credentials
DELETE/api/v1/tenant-service-accounts/{tenantId}/{serviceAccountId}/credentials/{credentialId}route idsservice-account.rotate-credentials
DELETE/api/v1/tenant-service-accounts/{tenantId}/{serviceAccountId}/credentials/{credentialId}/purgeroute idsservice-account.rotate-credentials

An empty tenantId in the route is rejected by the same required-tenant filter that guards the query parameter, so it answers 400. A non-server principal naming a tenant other than its own is answered 403.

Tenant settings​

/api/v1/tenant-settings

MethodPathRequired parametersPermission
GET/api/v1/tenant-settingstenantId (query)tenant.settings.read
GET/api/v1/tenant-settings/{settingName}settingName (route), tenantId (query)tenant.settings.read
POST/api/v1/tenant-settingstenantId (query), bodytenant.settings.write
PUT/api/v1/tenant-settingstenantId (query), bodytenant.settings.write
DELETE/api/v1/tenant-settings/{settingName}settingName (route), tenantId (query)tenant.settings.write

Tenants​

/api/v1/tenants

MethodPathRequired parametersPermission
POST/api/v1/tenantsbodyserver.tenants.write
GET/api/v1/tenantsnoneserver.tenants.read
GET/api/v1/tenants/{tenantId}tenantId (route)server.tenants.read
PUT/api/v1/tenants/{tenantId}tenantId (route), bodyserver.tenants.write
DELETE/api/v1/tenants/{tenantId}tenantId (route)server.tenants.delete

Test email​

/api/v1/test-email

MethodPathRequired parametersPermission
POST/api/v1/test-emailnoneserver.settings.write

Sends a test message to the email address of the calling user. A caller with no user record, such as a service account, is answered 400.

User groups​

/api/v1/user-groups

MethodPathRequired parametersPermission
POST/api/v1/user-groupstenantId (query), bodytenant.user-groups.write
GET/api/v1/user-groupstenantId (query)tenant.user-groups.read
GET/api/v1/user-groups/{userGroupId}userGroupId (route), tenantId (query)tenant.user-groups.read
PUT/api/v1/user-groups/{userGroupId}userGroupId (route), tenantId (query), bodytenant.user-groups.write
DELETE/api/v1/user-groups/{userGroupId}userGroupId (route), tenantId (query)tenant.user-groups.write
POST/api/v1/user-groups/{userGroupId}/membersuserGroupId (route), tenantId (query), bodyuser-group.assign-users on that group
DELETE/api/v1/user-groups/{userGroupId}/membersuserGroupId (route), tenantId (query), bodyuser-group.assign-users on that group

User preferences​

/api/v1/user-preferences

MethodPathRequired parametersPermission
GET/api/v1/user-preferencestenantId (query)Any authenticated principal, self-scoped
GET/api/v1/user-preferences/{name}name (route), tenantId (query)Any authenticated principal, self-scoped
POST/api/v1/user-preferencestenantId (query), bodyAny authenticated principal, self-scoped
PUT/api/v1/user-preferencestenantId (query), bodyAny authenticated principal, self-scoped

User server settings​

/api/v1/user-server-settings

MethodPathRequired parametersPermission
GET/api/v1/user-server-settings/file-upload-max-sizenoneAny authenticated principal
GET/api/v1/user-server-settings/decommission-statusnoneAny authenticated principal

Responses are cached for 30 seconds. Neither route takes tenantId, because both report server configuration.

User storage​

/api/v1/user-storage

MethodPathRequired parametersPermission
POST/api/v1/user-storagetenantId (query), bodyAny authenticated principal, self-scoped
GET/api/v1/user-storage/{key}key (route), tenantId (query)Any authenticated principal, self-scoped
DELETE/api/v1/user-storage/{key}key (route), tenantId (query)Any authenticated principal, self-scoped

Users​

/api/v1/users

MethodPathRequired parametersPermission
POST/api/v1/userstenantId (query), bodytenant.users.write
GET/api/v1/userstenantId (query)tenant.users.read
DELETE/api/v1/users/{userId}userId (route), tenantId (query)tenant.users.delete
POST/api/v1/users/{userId}/reset-passworduserId (route), tenantId (query)tenant.users.write
POST/api/v1/users/{userId}/personal-access-tokensuserId (route), tenantId (query), bodypersonal-access-token.others.write
GET/api/v1/users/{userId}/personal-access-tokensuserId (route), tenantId (query)personal-access-token.others.read
PUT/api/v1/users/{userId}/personal-access-tokens/{tokenId}route ids, tenantId (query), bodypersonal-access-token.others.write
DELETE/api/v1/users/{userId}/personal-access-tokens/{tokenId}route ids, tenantId (query)personal-access-token.others.write

There is no V1 route that updates a user's display name or roles. Creating a user can grant initial permissions through the request body, and permission changes after that go through Permission assignments.

Version​

/api/v1/version

MethodPathRequired parametersPermission
GET/api/v1/version/agentnoneAnonymous
GET/api/v1/version/servernoneAnonymous
GET/api/v1/version/release-notesnoneAnonymous

Responses are cached for 60 seconds.


Internal endpoints (deprecated)​

This is the surface ControlR's own Blazor front-end calls. It is documented so you can recognize it, migrate off it, and read the front-end's traffic. Do not build new integrations on it.

Three things about it matter when you move to V1:

  • These routes take the tenant from the caller's own tenant claim, so they have no tenantId parameter. V1 requires one.
  • Deprecated actions carry [ApiDeprecated]. The internal OpenAPI document marks each deprecated: true and names the V1 route in x-replacement-route. 71 of the 79 routes carry it.
  • Failure shapes differ. V1 answers every failure with an RFC 9457 ProblemDetails body, while several Internal routes answer some failures with a bare string.

79 routes, 22 controllers. Where the replacement column says a query parameter is required, add it to the V1 route.

Auth (/api/auth)​

Not deprecated. There is no V1 twin, because these routes exist to establish a browser session.

MethodPathAuth
POST/api/auth/change-passwordAny authenticated principal
POST/api/auth/change-password-with-credentialsAnonymous, rate limited
POST/api/auth/complete-password-resetAnonymous, rate limited
POST/api/auth/interactive-loginAnonymous, rate limited. Answers 404 unless EnableInteractiveBearerLogin is set
POST/api/auth/logoutAny authenticated principal
GET/api/auth/meAny authenticated principal

The framework's own MapIdentityApi set is mounted under the same /api/auth prefix, and only when AppOptions:EnableInteractiveBearerLogin is set. That flag is false in the shipped appsettings.json, so a production server maps none of these. When it is on, the mounted set is the framework's list, which is camelCase where ControlR's own routes are kebab-case:

POST /api/auth/register, POST /api/auth/login, POST /api/auth/refresh, GET /api/auth/confirmEmail, POST /api/auth/resendConfirmationEmail, POST /api/auth/forgotPassword, POST /api/auth/resetPassword, POST /api/auth/manage/2fa, GET /api/auth/manage/info, POST /api/auth/manage/info.

POST /api/auth/register is intercepted by ControlR. It creates the user through the same path the web UI uses, and answers 404 when public registration is disabled in server settings.

Devices (/api/devices)​

MethodDeprecated routeV1 replacement
GET/api/devices/api/v1/devices
GET/api/devices/summary/api/v1/devices/summary
POST/api/devices/search/api/v1/devices/search
GET/api/devices/{deviceId}/api/v1/devices/{deviceId}
PATCH/api/devices/{deviceId}/alias/api/v1/devices/{deviceId}/alias
DELETE/api/devices/{deviceId}/api/v1/devices/{deviceId}
POST/api/devices/delete-many/api/v1/devices/delete-many

The V1 search and delete-many routes evaluate device access per candidate rather than pre-filtering by the caller's tenant claim, so they also work for server-scoped principals. The V1 delete-many reports refused ids in the body instead of dropping them silently.

Device file system (/api/device-file-system)​

MethodDeprecated routeV1 replacement
POST/api/device-file-system/contents/api/v1/device-file-system/contents?tenantId={tenantId}
POST/api/device-file-system/path-segments/api/v1/device-file-system/path-segments?tenantId={tenantId}
POST/api/device-file-system/root-drives/api/v1/device-file-system/root-drives?tenantId={tenantId}
POST/api/device-file-system/subdirectories/api/v1/device-file-system/subdirectories?tenantId={tenantId}
POST/api/device-file-system/validate-path/{deviceId}/api/v1/device-file-system/validate-path/{deviceId}?tenantId={tenantId}
POST/api/device-file-system/create-directory/{deviceId}/api/v1/device-file-system/create-directory/{deviceId}?tenantId={tenantId}
POST/api/device-file-system/upload/{deviceId}/api/v1/device-file-system/upload/{deviceId}?tenantId={tenantId}
GET/api/device-file-system/download/{deviceId}/api/v1/device-file-system/download/{deviceId}?tenantId={tenantId}
POST/api/device-file-system/download-archive/{deviceId}/api/v1/device-file-system/download-archive/{deviceId}?tenantId={tenantId}
POST/api/device-file-system/download-archive/{deviceId}/formnone
DELETE/api/device-file-system/delete-path/{deviceId}/api/v1/device-file-system/delete-path/{deviceId}?tenantId={tenantId}
GET/api/device-file-system/logs/{deviceId}/api/v1/device-file-system/logs/{deviceId}?tenantId={tenantId}
GET/api/device-file-system/logs/{deviceId}/contents/api/v1/device-file-system/logs/{deviceId}/contents?tenantId={tenantId}

The form route has no replacement. It exists for the browser's form-encoded upload in the web front-end. Where the V1 route names the device in the path, its request body does not repeat a device id.

Desktop preview (/api/desktop-preview)​

MethodDeprecated routeV1 replacement
GET/api/desktop-preview/{deviceId}/{targetProcessId}/api/v1/desktop-preview/{deviceId}/{targetProcessId}?tenantId={tenantId}

Device tags (/api/device-tags)​

MethodDeprecated routeV1 replacement
POST/api/device-tags/api/v1/device-tags?tenantId={tenantId}
DELETE/api/device-tags/{deviceId}/{tagId}/api/v1/device-tags/{deviceId}/{tagId}?tenantId={tenantId}

Effective user preferences (/api/effective-user-preferences)​

MethodDeprecated routeV1 replacement
GET/api/effective-user-preferences/api/v1/effective-user-preferences?tenantId={tenantId}

Installer keys (/api/installer-keys)​

MethodDeprecated routeV1 replacement
POST/api/installer-keys/api/v1/installer-keys, with TenantId in the body
GET/api/installer-keys/api/v1/installer-keys?tenantId={tenantId}
GET/api/installer-keys/usages/{keyId}/api/v1/installer-keys/{keyId}/usages?tenantId={tenantId}
PUT/api/installer-keys/rename/api/v1/installer-keys/{keyId}?tenantId={tenantId}, body carries only friendlyName, answers 204
DELETE/api/installer-keys/{id}/api/v1/installer-keys/{keyId}?tenantId={tenantId}

Note the two shape changes. The V1 usages route puts the id in the path before usages, and the V1 rename is a PUT against the key id rather than a fixed /rename path. V1 list routes also return an Items envelope rather than a bare array.

Invites (/api/invites)​

MethodDeprecated routeV1 replacement
POST/api/invites/api/v1/invites?tenantId={tenantId}
GET/api/invites/api/v1/invites?tenantId={tenantId}
DELETE/api/invites/{inviteId}/api/v1/invites/{inviteId}?tenantId={tenantId}
POST/api/invites/acceptnone

POST /api/invites/accept is anonymous and has no V1 twin. An accepted invite signs the invitee in, so it stays on the session surface.

Logon tokens (/api/logon-tokens)​

MethodDeprecated routeV1 replacement
POST/api/logon-tokens/api/v1/logon-tokens/user, with TenantId in the body. Server-scoped callers use /api/v1/logon-tokens/external

Personal access tokens (/api/personal-access-tokens)​

MethodDeprecated routeV1 replacement
POST/api/personal-access-tokens/api/v1/personal-access-tokens?tenantId={tenantId}, answers 201
GET/api/personal-access-tokens/api/v1/personal-access-tokens?tenantId={tenantId}
PUT/api/personal-access-tokens/{id}/api/v1/personal-access-tokens/{id}?tenantId={tenantId}
DELETE/api/personal-access-tokens/{id}/api/v1/personal-access-tokens/{id}?tenantId={tenantId}, answers 204

Tags (/api/tags)​

MethodDeprecated routeV1 replacement
GET/api/tags/api/v1/tags?tenantId={tenantId}
POST/api/tags/api/v1/tags?tenantId={tenantId}, answers 201
PUT/api/tags/api/v1/tags/{tagId}?tenantId={tenantId}, body carries only name
DELETE/api/tags/{tagId}/api/v1/tags/{tagId}?tenantId={tenantId}

The V1 rename takes the tag id in the route instead of in the body.

Tenant settings (/api/tenant-settings)​

MethodDeprecated routeV1 replacement
GET/api/tenant-settings/api/v1/tenant-settings?tenantId={tenantId}
GET/api/tenant-settings/{name}/api/v1/tenant-settings/{name}?tenantId={tenantId}
POST/api/tenant-settings/api/v1/tenant-settings?tenantId={tenantId}
PUT/api/tenant-settings/api/v1/tenant-settings?tenantId={tenantId}
DELETE/api/tenant-settings/{name}/api/v1/tenant-settings/{name}?tenantId={tenantId}

Test email (/api/test-email)​

MethodDeprecated routeV1 replacement
POST/api/test-email/api/v1/test-email

User preferences (/api/user-preferences)​

MethodDeprecated routeV1 replacement
GET/api/user-preferences/api/v1/user-preferences?tenantId={tenantId}
GET/api/user-preferences/{name}/api/v1/user-preferences/{name}?tenantId={tenantId}
POST/api/user-preferences/api/v1/user-preferences?tenantId={tenantId}
PUT/api/user-preferences/api/v1/user-preferences?tenantId={tenantId}

User server settings (/api/user-server-settings)​

MethodDeprecated routeV1 replacement
GET/api/user-server-settings/file-upload-max-size/api/v1/user-server-settings/file-upload-max-size
GET/api/user-server-settings/decommission-status/api/v1/user-server-settings/decommission-status

User storage (/api/user-storage)​

MethodDeprecated routeV1 replacement
POST/api/user-storage/api/v1/user-storage?tenantId={tenantId}
GET/api/user-storage/{key}/api/v1/user-storage/{key}?tenantId={tenantId}
DELETE/api/user-storage/{key}/api/v1/user-storage/{key}?tenantId={tenantId}

Users (/api/users)​

MethodDeprecated routeV1 replacement
POST/api/users/api/v1/users?tenantId={tenantId}
GET/api/users/api/v1/users?tenantId={tenantId}
DELETE/api/users/{userId}/api/v1/users/{userId}?tenantId={tenantId}
POST/api/users/{userId}/reset-password/api/v1/users/{userId}/reset-password?tenantId={tenantId}
POST/api/users/{userId}/personal-access-tokens/api/v1/users/{userId}/personal-access-tokens?tenantId={tenantId}, answers 201
GET/api/users/{userId}/personal-access-tokens/api/v1/users/{userId}/personal-access-tokens?tenantId={tenantId}
PUT/api/users/{userId}/personal-access-tokens/{tokenId}/api/v1/users/{userId}/personal-access-tokens/{tokenId}?tenantId={tenantId}
DELETE/api/users/{userId}/personal-access-tokens/{tokenId}/api/v1/users/{userId}/personal-access-tokens/{tokenId}?tenantId={tenantId}

Server administration​

MethodDeprecated routeV1 replacement
GET/api/server-stats/api/v1/server-stats
GET/api/server-logs/get-aspire-url/api/v1/server-logs/get-aspire-url
GET/api/server-alert/api/v1/server-alert
POST/api/server-alert/api/v1/server-alert
GET/api/public-server-settings/api/v1/public-server-settings

/api/public-server-settings is anonymous on both surfaces. Its V1 replacement adds IsPublicRegistrationEnabled and DisableDesktopPreview fields under V1 type names.

Version (/api/version)​

MethodDeprecated routeV1 replacement
GET/api/version/agent/api/v1/version/agent
GET/api/version/server/api/v1/version/server
GET/api/version/release-notes/api/v1/version/release-notes

Groups with no Internal surface​

These groups exist only under /api/v1/. There is no /api/... controller for any of them, so a route you construct there returns 404.

  • Permission assignments, including the catalog, presets, batch, and replace routes
  • Effective permissions
  • Authorization change logs
  • User groups
  • Device groups
  • Customers
  • Tenant service accounts
  • Server service accounts
  • Deployment options
  • The tenants list

Agent endpoints (/api/agent/*)​

These are the HTTP calls a device agent makes. They are anonymous, and the agent authenticates with installer-key credentials. Both controllers are also mounted under a legacy root for agents that predate the /api/agent prefix.

MethodPathAuthDescription
POST/api/agent/devicesInstaller key in bodyRegister a device. InstallerKeyId and InstallerKeySecret in the body are validated, and the device inherits the key's tenant
POST/api/devicesInstaller key in bodyLegacy alias of the route above
GET/api/agent/updates/get-bundle-metadata/{runtime}AnonymousDownload metadata for the agent bundle. runtime is a runtime identifier such as win-x64
GET/api/agent-update/get-bundle-metadata/{runtime}AnonymousLegacy alias of the route above

Responses from the bundle-metadata route are cached for 600 seconds. Everything else an agent does travels over the AgentHub.


SignalR hubs​

Two hubs are mapped: /hubs/agent and /hubs/viewer.

Protocol. The server registers both the MessagePack and JSON hub protocols. No ControlR client adds MessagePack, and JSON is the .NET client default, so real traffic negotiates JSON. MessagePack is offered, not used. The server registers the JSON protocol with case-insensitive property names. Contract DTOs carry MessagePackObject attributes, so the MessagePack path is wired on the DTO side, and a client that asks for it can work. Nothing in the shipped product asks for it.

AgentHub (/hubs/agent)​

The hub class carries no [Authorize], so the connection is anonymous. Device identity is carried by the update payload rather than by the connection. UpdateDevice is rejected once the device has a stored public key, which forces that device onto UpdateDeviceSigned. The signed route verifies the signature against the stored key, or against the key the payload presents when the device has none yet, and rejects a stale timestamp. A payload naming a device the server has never seen is rejected as an unknown device unless AllowAgentsToSelfBootstrap is enabled, and self-bootstrap only works on a single-tenant server.

Agent to server. These are the methods the agent invokes. They are the IAgentHub surface, and it is the whole invokable surface of this hub.

MethodParametersReturns
UpdateDeviceDeviceUpdateRequestDtoHubResult<DeviceResponseDto>
UpdateDeviceSignedSignedDto<DeviceUpdateRequestDto>HubResult<DeviceResponseDto>
SendChatResponseChatResponseHubDtobool
SendTerminalOutputToViewerviewerConnectionId, TerminalOutputDtovoid
GetFileStreamFromViewerFileUploadHubDtoChannelReader<byte[]>
SendDesktopPreviewStreamstreamId, ChannelReader<byte[]>void
SendDirectoryContentsStreamstreamId, directoryExists, ChannelReader<FileSystemEntryDto[]>void
SendFileContentStreamstreamId, ChannelReader<byte[]>HubResult
SendSubdirectoriesStreamstreamId, ChannelReader<FileSystemEntryDto[]>void

UpdateDevice and UpdateDeviceSigned are how a device reports state. The stream methods push chunked file, directory, and preview data toward the viewer that asked for it, keyed by the streamId the server issued.

Server to agent. These are IAgentHubClient methods. The server invokes them on the agent, so an agent implements them, and a viewer never calls them directly. A viewer's request reaches the agent through the ViewerHub method that relays it.

MethodParameters
CloseChatSessionsessionId, targetProcessId
CloseTerminalSessionterminalSessionId
CreateDirectoryCreateDirectoryHubDto
CreateRemoteControlSessionRemoteControlSessionRequestDto
CreateTerminalSessionterminalSessionId, viewerConnectionId
CreateVncSessionVncSessionRequestDto
DeleteFileFileDeleteHubDto
DownloadFileFromViewerFileUploadHubDto
GetActiveDesktopSessionsnone
GetLogFilesnone
GetPathSegmentsGetPathSegmentsHubDto
GetPwshCompletionsPwshCompletionsRequestDto
GetRootDrivesGetRootDrivesRequestDto
InvokeCtrlAltDelInvokeCtrlAltDelRequestDto
InvokeWakeDeviceWakeDeviceDto
ReceiveAgentUpdateTriggernone
ReceivePowerStateChangePowerStateChangeType
ReceiveTerminalInputTerminalInputDto
RefreshDeviceInfonone
RequestDesktopPreviewDesktopPreviewRequestDto
RequestRemoteControlPermissiontargetProcessId
SendChatMessageChatMessageHubDto
StreamDirectoryContentsDirectoryContentsStreamRequestHubDto
StreamFileContentsStreamFileContentsRequestHubDto
StreamSubdirectoriesSubdirectoriesStreamRequestHubDto
TestVncConnectionport
UninstallAgentreason
UploadArchiveToViewerFileArchiveDownloadHubDto
UploadFileToViewerFileDownloadHubDto
ValidateFilePathValidateFilePathHubDto

ViewerHub (/hubs/viewer)​

The hub carries [Authorize], so a viewer needs an authenticated session. Beyond that, an operation authorizes the caller against the specific device permission it needs, through one shared check. Terminal input needs device.terminal.use, a remote-control or VNC request needs device.remote-control.connect or device.vnc-relay.connect, a refresh needs device.read, and so on. A refused check comes back as a HubResult failure whose reason is "Forbidden." Holding the connection does not grant access to a device.

Viewer to server. This is the IViewerHub surface, 46 methods. Most of it was doubled during 2026-09 (v0.28.x). The positional-argument form became [Obsolete] in favor of a DTO-based *2 method, and the device id moved inside the DTO. 21 methods carry the obsolete marker. Call the *2 form. The obsolete form is still invokable, and several of them return a bare value where the replacement wraps the answer in HubResult, so a null or empty answer is ambiguous on the old form and explicit on the new one.

MethodStatusParameters
AddViewerActivityObsolete, use AddViewerActivity2activityName
AddViewerActivity2currentAddViewerActivityRequestDto
CloseChatSessionObsolete, use CloseChatSession2deviceId, sessionId, targetProcessId
CloseChatSession2currentCloseChatSessionRequestDto
CloseTerminalSessionObsolete, use CloseTerminalSession2deviceId, terminalSessionId
CloseTerminalSession2currentCloseTerminalSessionRequestDto
CreateTerminalSessionObsolete, use CreateTerminalSession2deviceId, terminalSessionId
CreateTerminalSession2currentCreateTerminalSessionRequestDto
DisposeDeviceAccessActivitycurrentnone
GetActiveDesktopSessionsObsolete, use GetActiveDesktopSessions2deviceId
GetActiveDesktopSessions2currentGetActiveDesktopSessionsRequestDto
GetDeviceAccessPermissionsObsolete, use GetDeviceAccessPermissions2deviceId
GetDeviceAccessPermissions2currentGetDeviceAccessPermissionsRequestDto
GetPwshCompletionscurrentPwshCompletionsRequestDto
InvokeCtrlAltDelObsolete, use InvokeCtrlAltDel2deviceId, targetDesktopProcessId, desktopSessionType
InvokeCtrlAltDel2currentInvokeCtrlAltDelViewerRequestDto
RefreshDeviceInfoObsolete, use RefreshDeviceInfo2deviceId
RefreshDeviceInfo2currentRefreshDeviceInfoRequestDto
RequestRemoteControlPermissionObsolete, use RequestRemoteControlPermission2deviceId, targetProcessId
RequestRemoteControlPermission2currentRequestRemoteControlPermissionRequestDto
RequestRemoteControlSessionObsolete, use RequestRemoteControlSession2deviceId, RemoteControlSessionRequestDto
RequestRemoteControlSession2currentRemoteControlSessionRequestDto
RequestVncSessionObsolete, use RequestVncSession2deviceId, VncSessionRequestDto
RequestVncSession2currentVncSessionRequestDto
SendAgentUpdateTriggerObsolete, use SendAgentUpdateTrigger2deviceId
SendAgentUpdateTrigger2currentSendAgentUpdateTriggerRequestDto
SendChatMessageObsolete, use SendChatMessage2deviceId, ChatMessageHubDto
SendChatMessage2currentChatMessageHubDto
SendDtoToAgentcurrentSendDtoToAgentRequestDto
SendPowerStateChangeObsolete, use SendPowerStateChange2deviceId, PowerStateChangeType
SendPowerStateChange2currentSendPowerStateChangeRequestDto
SendTerminalInputObsolete, use SendTerminalInput2deviceId, TerminalInputDto
SendTerminalInput2currentSendTerminalInputRequestDto
SendWakeDeviceObsolete, use SendWakeDevice2deviceId, macAddresses
SendWakeDevice2currentSendWakeDeviceRequestDto
StartDeviceAccessActivityObsolete, use StartDeviceAccessActivity2deviceId
StartDeviceAccessActivity2currentStartDeviceAccessActivityRequestDto
SubscribeToDeviceHeartbeatsObsolete, use SubscribeToDeviceHeartbeats2deviceIds
SubscribeToDeviceHeartbeats2currentSubscribeToDeviceHeartbeatsRequestDto
TestVncConnectionObsolete, use TestVncConnection2deviceId, port
TestVncConnection2currentTestVncConnectionRequestDto
UninstallAgentObsolete, use UninstallAgent2deviceId, reason
UninstallAgent2currentUninstallAgentRequestDto
UnsubscribeFromDeviceHeartbeatsObsolete, use UnsubscribeFromDeviceHeartbeats2deviceIds
UnsubscribeFromDeviceHeartbeats2currentUnsubscribeFromDeviceHeartbeatsRequestDto
UploadFilecurrentFileUploadMetadata, ChannelReader<byte[]>

There is no SendDtoToUserGroups method on this hub, in either direction. Subscribing to heartbeats is capped at 100 device ids per call.

Server to viewer. These are IViewerHubClient methods. The server pushes them to connected viewers.

MethodParameters
ReceiveDeviceUpdateDeviceResponseDto
ReceiveTerminalOutputTerminalOutputDto
ReceiveChatResponseChatResponseHubDto
ReceiveServerStatsServerStatsDto
InvokeToastToastInfo

Route inventory​

SurfaceRoutesNotes
V1 (/api/v1/*)13031 groups
Internal (/api/*)7922 controllers, 71 marked deprecated
Agent (/api/agent/*, plus two legacy aliases)42 controllers, each mounted twice
Identity API under /api/auth10Framework-provided, mapped only when EnableInteractiveBearerLogin is set

Every HTTP route above traces to a route attribute on a controller or to a HttpConstants entry. Every hub method traces to IAgentHub, IAgentHubClient, IViewerHub, IViewerHubClient, or the hub implementation. The hub method lists are complete, and a method not named there is not invokable.