Kuiper API Reference
Kuiper exposes a REST API that can perform nearly all configuration and deployment
actions available in the UI. This page records what the API surface actually is —
both the endpoints Epic documents in the Kuiper API Specifications PDF and the
undocumented ones discovered by inspecting a live install — so the
kuiper role's API configuration step has an authoritative
map to build against.
Authentication
POST /Kuiper/api/auth/v1 with a JSON body:
{
"username": "DOMAIN\\user",
"password": "…",
"grant_type": "password",
"client_id": "CLIENTIDNULL"
}
Returns a JWT (token, token_type, expires_in — currently a 7-day expiry).
Send it on every subsequent call as Authorization: Bearer <token>.
Notes learned in the Azure IRE environment:
- The username must be the account's sAMAccountName, which AD truncates to 20
characters. The UPN form (
user@domain) and the untruncated display name both return401 Unauthorizedwith an empty body. - MFA-enrolled users must supply a
passcode(TOTP). Duo Web SDK / OIDC users cannot authenticate through the API at all. - Prefer a dedicated, least-privilege Kuiper user for automation rather than a domain admin.
Two coexisting routing schemes
A live install serves two route conventions, both accepting the same bearer token:
- Public API — resource-first, version-suffixed:
/Kuiper/api/{resource}/v{n}(e.g./Kuiper/api/machines/v2,/Kuiper/api/channels/v1). This is what the Epic PDF documents and what automation should target. - Internal / UI API — version-first:
/Kuiper/api/v1/{resource}(e.g./Kuiper/api/v1/servers,/Kuiper/api/v1/settings/*). This is what the Kuiper web UI itself calls. It is broader and more granular but undocumented and unsupported — treat it as a last resort and expect it to change between versions.
Newer controllers use ASP.NET API-versioning and are strict about method +
version; calling the wrong method returns 405 UnsupportedApiVersion (which is
still a useful "this endpoint exists" signal).
How the surface was discovered
Kuiper ships no Swagger/OpenAPI document (swagger.json / openapi.json
return 404; the SPA catch-all returns the app shell for unknown paths). The
authoritative source is therefore the deployed application itself, at
C:\Epic\ECSM Tools\Web:
- String-scan
bin\*.dllforapi/…route literals and*ApiControllerclass names. The*ApiControllerclasses are the public API surface. - Confirm candidates with read-only
GETprobes on the host (https://localhost/Kuiper/…).200/400/405/415mean the route exists;404with a"No type was found that matches the controller"body means that path is not it.
Documented endpoint families (Epic PDF)
auth, actions, applicationinventory, auditlog, backgroundprocesses,
channels (+ Hyperdrive package authorization/activation), clientregistrationkey,
credentials, deploymentextensions, directorygroups (retired May 2026 →
mapgroups), domains, environments, eki, epicwebview2, eventmessages,
groups (+ grouptags, group membership, per-group credential), Kuiper Cache
Service health, Hyperdrive filelog, ldapconnection, machines (v2/v3),
mapgroups, mission, packages, printers (print queue), servicegroup,
settings/* (deployment, devices, environments, extensibility, filemanagement,
hyperdrive-registration-only, infrastructuredeployment (new May 2026:
enableNtlm — the "Remote authentication" toggle — and
maximumConcurrentActions; GET/PATCH with an {"attributes": {...}} body),
machinecleanup, mfaprovider, mychart, nebula,
packages/packagesources, reauthentication, satellite, su-eeds, systempulse),
thirdpartyfilepaths, upgradeconsole, validation.
Undocumented endpoints (confirmed live)
Discovered by DLL scrape and confirmed against the running server; not in the Epic PDF. Routes/behaviour may change without notice.
| Endpoint | Method(s) | Notes |
|---|---|---|
api/settings/v1/satellite/publish |
POST (200) | Publishes the Satellite build shipped with the Kuiper install to production machines — the REST equivalent of the UI's "Publish Settings/PublishSatelliteToProduction). Requires a body ({} is fine; a body-less POST gets HTTP 411) and returns the updated SatelliteSettings, whose satelliteVersion goes from null to the published version. No published-version staleness info is exposed, so idempotency is limited to "publish only while satelliteVersion is null". |
api/thirdpartysources/v1 |
GET (200) | Third-party download sources; distinct from settings/packagesources and thirdpartyfilepaths. |
api/settings/v1/packagesources/{id} |
PATCH (200) | Update one package source, JSON:API {attributes: {...}} body (a flat body 400s UnknownKeyInPrimaryDataObject; PUT, and collection-level PATCH/PUT, all 405). The built-in Base Package Source (Kuiper's default package-staging location, sourceType: "Base Package Source", id 1 on a fresh install) is repointed this way — see Base Package Source. Two gotchas: changing userName without a non-blank password 409s ("Changing the user name without also updating the password is not allowed"), so a passwordless MSA can't be set here (and isMsa is not a valid attribute); and maxConcurrentDownloads must be 1–200 on write even though a fresh source reads 0. |
api/cloudservice/v1 |
write (GET→405) | Cloud service token management; pairs with settings/EnableCloudServiceTokenManagement. |
api/v1/servers |
GET (200) | Server records — richer than public machines (associatedApplications, aliases). Accepts a ?groupId={id} filter that returns only that group's members — the read-back for group membership (see below). |
api/groups/v1/{application} |
GET (200) / POST (200/201) | Application groups. Create body is {attributes: {name, systemPulseMetricGroup, deploymentSetting, ...}}. systemPulseMetricGroup is the group's Metric group — a friendly application-name string (Hyperspace Web, Interconnect, System Pulse, default Disabled); the values map to I_DistributedMetricDefinitions rows. A deploymentSetting key is required (HyperspaceWeb needs a populated one; Satellite Only — the System Pulse monitoring app — accepts an empty {} and Kuiper fills defaults). Every application uses this route, Interconnect included, from Kuiper August 2026 onward — earlier builds returned 400 "Creating a Interconnect application group is currently not supported" and needed the MVC path. For an Interconnect group the server expands deploymentSetting.instances into one secondary application group per entry and returns them under relationships.instances, so no SecondaryApplicationGroups array and no DistributedMetricDefinitionId are sent. |
api/groups/v1/{application}/{groupId} |
PATCH (200) | Update a group. The body must carry the group's full attributes (including deploymentSetting) — a partial body 403s with "Unable to resolve application deployment setting". A masked secret read back from GET (e.g. AHSW's perimeterServerFixedKey: "***") must be replaced with the real value or it is overwritten. The role does not PATCH groups (create-if-absent + nightly rebuild); used only for out-of-band live fixes. |
api/groups/v1/{application}/{groupId}/machines |
POST (201) / DELETE (204) | Group membership — see Group machine, environment, and package membership. GET is unsupported (405). |
api/machines/v3 |
GET/POST (200/201) | Machine records. Create body is a single JSON:API primary object {type: "Machine", attributes: {name, machineType, requestedMaintenanceMode: "In service", associatedApplications: [...display names...]}} — not wrapped in data, and omitting requestedMaintenanceMode 500s with "Maintenance mode expected and not defined". associatedApplications uses display names with spaces (Hyperspace Web, Interconnect, Kuiper, Hyperspace); an unknown name 400s, an empty list is accepted. Create does not enforce DNS resolution. |
api/v1/machines/{id} |
PATCH/PUT/DELETE | Update (body type Server, ServerSchema) or delete one machine by id. Used to clean up test records. |
api/v1/maintenance/availableStatuses |
GET (200) | Maintenance enum: 0 NotUsed, 1 InService, 2 TransitioningOutOfService, 3 OutOfService. |
api/v1/settings/* |
GET/PUT/POST | Granular internal toggles with no public equivalent — CDN download source, key rotation, EPS font embedding, credentials auto-logon, Hyperdrive publish-delay max, system-assessment child-machine count, etc. |
api/v1/deployment/* |
GET/PUT | Deployment tuning — psOpenTimeoutOverride, skipScannedPrerequisites, sqlConnectionRunnerDefaultMaxAttempts, winRmHttpsOverrides. |
api/v1/distributed/* |
GET/PUT | Agent tuning — action-retrieval settings, minutes-between-actions, max simultaneous downloads. |
api/v1/executepowershellscript/machine/{machineId} |
POST | Runs a registered PowerShell extension command on a machine (body requires ModuleName + command, same shape as Deployment Extensions — not arbitrary script text). Powerful; gate behind explicit opt-in. |
api/v1/Groups/HyperspaceWeb/EnablePerimeterAuthenticationUI?enable=true |
POST (204) | Exposes the "Set as perimeter authentication server" checkbox on Hyperspace Web groups instance-wide (Epic's Enable-PerimeterHSWebUI.ps1). Requires an empty JSON body ({}; a body-less POST 411s, like Satellite publish) and returns 204 No Content. Only unhides the option — the per-group toggle is deploymentSetting.isPerimeterServer on the group create body (requires allowOAuth2Authorization: false + a non-blank perimeterServerFixedKey; see API Configuration). No GET to read the current UI-enablement state, so idempotency is limited to "POST only when the operator opts in"; Kuiper treats a repeat enable as a no-op. Driven by kuiper_enable_perimeter_hsw_ui. |
Group machine, environment, and package membership
Assigning machines, environments, and packages to a group are REST actions, so
the role uses the REST routes for every application, Interconnect included.
roles/kuiper/tasks/fleet/group_machines.yml,
roles/kuiper/tasks/fleet/group_environments.yml, and
roles/kuiper/tasks/fleet/group_packages.yml drive them.
| Action | Request | Result |
|---|---|---|
| Add machines | POST api/groups/v1/{application}/{groupId}/machines?isDowntimeTolerant=true, body a bare JSON array of machine ids ([3, 4]) |
201, body {"data":[{"id":3,"type":"Machine"}]} |
| Remove machines | DELETE …/machines, same bare-array body |
204 |
| Read members | GET api/v1/servers?groupId={groupId} |
member Server records (the …/machines route rejects GET with 405) |
| Add environments | POST api/groups/v1/{application}/{groupId}/environments, body a bare JSON array of environment ids ([3]) |
201, body {"data":[{"id":3,"type":"Environment"}]} |
| Remove environments | DELETE …/environments, same bare-array body |
204 |
| Read attachments | GET api/groups/v1/{application}/{groupId} → data[0].relationships.environments.ids |
the key is absent when no environments are attached (the …/environments route rejects GET with 405; GET api/v1/environments?groupId={id} also works) |
| List packages | GET api/packages/v1/{application} |
{data:[{id, attributes:{applicationName, version, longVersion, ras, dateAdded, packageFilePaths}}]} — the packages Kuiper has scanned from the Base Package Source. The bare api/packages/v1 404s, but the collection route with its trailing slash works: api/packages/v1/?applicationName=Hyperspace Web (spaces are accepted here, unlike the group routes). The v2 routes work too — api/packages/v2/{application}, /v2/{packageId}, /v2/?applicationName=… — and rename the ras attribute to raInfo, so remap that field if you version-switch. |
| Deploy packages | POST api/groups/v1/{application}/{groupId}/packages?isDowntimeTolerant={bool}&ignoreWarnings={bool}[&isStaging={bool}], body a bare JSON array of package ids ([2]) |
201, body {"data":[{"id":2,"type":"Package"}]}. isStaging stages instead of deploying — see Staging a package instead of deploying it |
| Uninstall packages | DELETE …/packages, same bare-array body |
204 |
| Read package status | GET api/groups/v2/{application}/{groupId}/packages, or …/packages/{packageId} for one |
200, one GroupPackageStatus per associated package, carrying attributes.isActive / attributes.isStaging and the deploy action's id, description and status. v2 only — the same path on v1 rejects GET with 405, which is why this route was long recorded here as write-only |
| Read deployed package ids | GET api/groups/v1/{application}/{groupId} → data[0].relationships.packages.ids |
same id space as the catalog id; key absent when none. Lists associations but cannot distinguish staged from active — use the v2 status route for that |
Gotchas, all confirmed live:
- The body is a top-level
System.Int32[]— sendids | to_json([3]). Any wrapping object ({machineIds: […]}, JSON:API{data:[…]}) 400s with "requires a JSON array"; an Ansible YAML-list body doesn't serialize and posts as empty. - Adding a member that is already present returns
403with{"errors":[{"message":"No Machine has been added. None were provided."}, …]}(environments and packages word it identically). So membership is idempotent by reading first and posting only the absent ids — never by catching the 403. - Group id is resolved by name from
GET api/groups/v1/{application}; machine ids fromGET api/v1/servers. The machines must already be registered. - The
{application}segment on the group routes is constrained:alpha?— letters only.api/groups/v1/hyperspacewebworks (case-insensitive) butapi/groups/v1/Hyperspace%20Web404s. Themachines,packages, andvalidationroutes do accept the spaced display name, so don't share one name-formatting helper across them. - A Hyperspace Web environment must carry an EMP record (its own
empIdor the configured default EMP) to attach; Interconnect environments need none. isDowntimeTolerant(on themachines,environments,servers, andpackagessub-routes) is the "install prerequisites" control:truelets Kuiper install prerequisites and reboot member machines as needed to complete the action;falsedoes neither.ignoreWarnings=true(packages) proceeds past voided release-authorization (RA) warnings instead of stalling for acknowledgement. The role adds machines and deploys packages withisDowntimeTolerant=true.
Staging a package instead of deploying it (isStaging)
The package sub-route takes a fourth, undocumented query parameter, isStaging,
which distributes a package's files to a group's machines without installing or
activating it — the REST equivalent of the Stage button on the Kuiper UI's
packages table. The full action signature, read off the deployed
GroupsApiController, is:
AddPackages(string application, int id, [FromBody] int[] pkgIds,
bool isDowntimeTolerant = false, // install prerequisites, reboot as needed
bool ignoreWarnings = false, // proceed past voided-RA warnings
bool isStaging = false) // stage instead of deploy/publish
So a stage is the ordinary deploy call plus one flag, on the same bare-id-array body:
POST api/groups/v1/{application}/{groupId}/packages?isStaging=true&isDowntimeTolerant=false
[12]
→ 201 {"data":[{"id":12,"type":"Package"}]}
Both v1 and v2 accept the flag (confirmed live), and the action requires the
per-application DeployApplicationSecurityPoint ("Deploy a Group") security
point, exactly like a deploy.
Staging is a stage in a schedule, not a boolean. Client (Satellite-managed,
"distributed") applications publish through distributed action sets, and each set
advances through a two-value stage enum,
Epic.Release.Management.Distributed.Staging.DistributedActionStage: Staging = 10,
Execution = 20. In the database, DistributedActionSets holds one row per publish,
joined to groups through Groups_DistributedActionSets, with
DistributedStageActiveTimes(DistributedActionSet_Id, Stage, ActiveTime) recording
when each stage becomes active. A package is staged while the set's highest active
stage is Staging, and active once it reaches Execution — which is what lets an
operator stage now and activate later.
Only some applications support it. Each application's IPublishDefinition
declares SupportsStagedPackages. In Kuiper 118.0 the applications that support
staged packages are Auto Local Printing, BCA PC, Enterprise Monitoring Collector,
EpicCare Remote Client, Epic Monitor, Hyperdrive Registration Only, Kuiper,
Satellite Only, WarpDrive, Welcome, Willow Ambulatory and WPF App Launcher. Two
caveats sit on that list:
- Hyperdrive does not stage. Its publish definition sets
PublishesWithChannels: trueinstead, so its staged-rollout mechanism is update channels and rings —api/channels/v1/{id}/authorization/{packageId},…/{id}/activation/{packageId}, andPATCH …/{id}/scheduledRings(aChannelRingSchemaV1withActivationTime,Percentage,PausedandMachineTagIds). - BCA PC, Epic Monitor, Welcome and Willow Ambulatory report
PublishSupport: RequiresConversion— their groups must be converted to distributed before a publish or stage will work, and the only route for that is MVCPOST Groups/ConvertToDistributed {groupId}.
The server applications this role deploys — Hyperspace Web and Interconnect — have
no publish support at all (PublishSupport: None); they use push orchestration.
That is where the one dangerous behaviour lives:
isStaging is silently ignored on an application that does not support staging
Confirmed live against a throwaway Hyperspace Web group with no member machines:
POST api/groups/v1/HyperspaceWeb/{id}/packages?isStaging=true with a package
that genuinely belongs to that application returned 201, and the v2
status read-back came back isStaging: false with a queued action described
"Deploying Hyperspace Web package 118.0.0 to …". The flag was accepted,
ignored, and a normal deployment started — with no error and no warning. Had
the call also passed isDowntimeTolerant=true, Kuiper would have installed
prerequisites and rebooted the group's machines.
So any caller must check SupportsStagedPackages for the target application
itself before posting, and confirm the outcome by reading
api/groups/v2/{application}/{groupId}/packages and asserting isStaging: true.
Never rely on the server to reject a staging request.
The MVC path fails closed by comparison, which makes it a usable pre-flight: against
an application with no publish support, both Publish/CanPublish and
Publish/StagePackage return {"Success": false, "Message": "Specified method is not
supported."}. It keys off the package's application, though, so it also rejects a
valid group handed a package from another application.
isStaging is not used by the role — fleet/group_packages.yml deploys, and the
IRE fleet is Hyperspace Web, Interconnect, Kuiper and Satellite Only groups. It is
documented here because it is the supported REST route for staging and because
discovering it corrected the read-back rows above. Two things about it remain
unverified, for want of a package belonging to a staging-capable application in
the dev environment: that isStaging=true on a supporting application reads back
isStaging: true with a "Staging …" action, and how activation of an
already-staged package is expressed. The database procs
(UpdateStagePublishScheduleForSet, BuildStageSchedule,
CheckStageActiveTimesAreNotDecreasing) suggest a later publish updates the existing
set's Execution activation time rather than creating a second set — meaning
"activate" is a plain publish against the same group and package. Confirm that before
building anything on it, because the usual "skip when the package id is already
associated" idempotency guard would wrongly skip such a call.
Security groups (api/security-groups/v1)
New in Kuiper August 2026 (119.x); found by reflecting over
Epic.Release.Management.Web.dll for SecurityGroupsApiController. It replaces
the entire Security/Settings → Security/FindAccounts → Security/Save MVC
flow the role used previously, for both kuiper_security_groups and the Super
Administrator bootstrap.
| Action | Request | Result |
|---|---|---|
| List | GET api/security-groups/v1 |
[{Id, Name, Description}] |
| Read one | GET api/security-groups/v1/{id} |
full model |
| Create | POST api/security-groups/v1 |
201, full model |
| Replace | PUT api/security-groups/v1/{id} |
200 |
| Delete | DELETE api/security-groups/v1/{id} |
204 |
| Add members | POST api/security-groups/v1/{id}/members |
200, the member list |
| Replace members | PUT api/security-groups/v1/{id}/members |
— |
| Remove members | POST api/security-groups/v1/{id}/members/remove |
— |
| Point catalog | GET api/security-groups/v1/template |
InfrastructureSecurityPoints, ApplicationSecurityPoints, DeviceSecurityPoints |
The body is the flat model, not JSON:API-wrapped:
{
"Name": "ODB Admins",
"Description": "Read/edit Environments and read/edit/run Environment Upgrades",
"JeaSupport": false,
"ApplicationGroupingType": "EnvironmentType",
"ApplicationEnvironmentTypes": ["Production", "NonProduction", "Training"],
"InfrastructureSecurityPoints": [{"Name": "ReadEnvironmentInfrastructureSecurityPoint"}],
"ApplicationSecurityPoints": [
{"Application": "Upgrade Console", "SecurityPoints": [{"Name": "CreateRunEnvUpgrades"}]}
],
"DeviceSecurityPoints": {"EnvironmentTypes": [], "SecurityPoints": []}
}
Three behaviours worth knowing before automating against it, all verified live:
Membersis ignored on create. However you populate it, the create response comes backMembers: []. Members land only through the/memberssub-route, so a create is always two calls.- Bare account names resolve server-side. Posting
[{"Name": "ODB Admins"}]storesAADDS\ODB Admins, so no account-search step and no domain-prefix configuration are needed. Since the stored form is qualified and the desired form usually is not, an add-if-absent comparison has to match on the whole string or on the part after the last backslash. - Permission dependencies auto-expand, exactly as the UI auto-checks them:
a group saved with only
CreateRunEnvUpgradesreads back withReadApplicationSecurityPointtoo. Idempotency must be desired ⊆ actual, not set equality.
Permissions are identified by security-point Name — the same strings the
template catalog lists — so the per-instance runtime integer ids that the MVC
model required are no longer involved anywhere.
MVC-driven actions
Some actions the web UI performs have no REST API and are reachable only through the Forms-authenticated MVC controllers. The role automates two — certificate import and deployment to machines, and System Pulse client registration — because the REST API cannot do them. These routes and model shapes are internal and may change between Kuiper builds.
The list shrinks as Kuiper ships routes. Interconnect group creation,
Super Administrator membership and security-group creation left it in Kuiper
August 2026 (119.x) and are now plain REST: group creation via
POST api/groups/v1/Interconnect (above) and the two security-group features via
the new api/security-groups/v1
controller.
The shared flow (roles/kuiper/files/kuiper_mvc_request.ps1):
- Log in —
GET /Kuiper/Account/Loginfor the anti-forgery cookie and hidden__RequestVerificationToken, thenPOSTthe credentials. The login form'sCanvasFingerprintfield is an MFA trusted-device fingerprint, not a login gate, so an empty value authenticates non-MFA accounts. Login sets a.ASPXAUTHticket, and is retried since it is occasionally refused. - Get a CSRF token —
GETan authenticated page and read its__RequestVerificationToken. - Request — POST/DELETE the action, sending the token as the
X-Csrf-Tokenheader (JSON posts don't carry the form field).
Notes:
HttpClient/Invoke-WebRequestfail the TLS send against this endpoint;HttpWebRequestwithExpect100Continue = $falseandKeepAlive = $falseworks.- An MVC save can reject an invalid model with HTTP
200,Success:false, and no message, so callers key success on a specific field (e.g.Data.GroupIDfor a created group) rather than the status code. - The MVC login account must be non-MFA, like the REST API account.
Endpoints per feature
- Interconnect group creation —
Groups/SaveWithValidatewith the group model as JSON.DistributedMetricDefinitionIdis a required field (reference data, one row per application type inI_DistributedMetricDefinitions);SystemPulseUrlIdmay be null; an emptyInstancesmap yields a valid empty group; atagthat doesn't already exist is rejected.Instancesis a map of instance name →IsChroniclesConfigured(bool), not enabled — every instance in it persists as oneSecondaryApplicationGroupsentry named<group>-<instance>(Kuiper stores the instance-name suffix) with a negative placeholderId(the server assigns a real id on save), so a non-Chronicles instance is listed withfalse; the parentInstancesmap alone does not stick. - Certificate import —
Certificates/Upload({base64RawData, passphrase}), listing withCertificates/GetNCertificates, deleting withCertificates/Delete. The Kuiper server / app-pool TLS certificate is still managed by thecertificate_authorityrole. - Certificate deployment to machines —
Certificates/AddCertificateToServer({certificateId, serverIds, markPrivateKeyExportable, siteName, port, hostName, requireSni}) pushes a stored certificate to machines and, whensiteName/portare non-null, binds it to that IIS site (null site/port deploys without a binding). Server ids come fromCertificates/GetServersAndApplications; the reverse operation isCertificates/DeleteCertificateFromServer({certificateId, serverIds, deleteCertificate}), andCertificates/ScanServers({ServerId}) inventories certificates already on a machine. Kuiper performs the deployment synchronously over a PSSession (WinRM, HTTP by default — seeapi/v1/deployment/winRmHttpsOverrides) from the Kuiper server to the machine, authenticated with a stored Windows credential associated with the machine's application — that account must be a local Administrator on the target, and an IIS binding requires IIS to be installed there. Per-server failures are reported inside aSuccess:trueenvelope: success for a server means it appears inData.SuccessServers; reasons land inData.FailureMessages. The certificate list'sAssociatedServers({Id, Name, SiteName, Port, HostName, RequireSni}) is the current deployment/binding state. - System Pulse client registration —
GET Settings/KuiperAuthreturns{Data: {ClientId, Secret}}, the credentials System Pulse must know to accept API calls from Kuiper (both auto-generated at install;POST Settings/RegenerateSystemPulseApiSecretrotates the secret). Related:GET Settings/KuiperId,GET Settings/SystemPulseApiSecret, andGET Settings/SystemPulseApiAuthUrl/{id}(builds the SP settings-page link the UI opens). The System Pulse half of the handshake is not Kuiper at all:POST <SP>/Admin/ApiAuth.aspx/GetSettingList(body{}) andPOST <SP>/Admin/ApiAuth.aspx/SaveSettingList(body{"settingArray": [{Id, FriendlyName, ClientId, Secret, Salt}]}) — ASP.NET page methods behind System Pulse's Forms login (Login/Login.aspx, AD membership provider, sAMAccountName usernames; carry the.ASPXAUTHcookie). System Pulse never returns stored secrets; re-saving an existing entry with an emptySecretkeeps the stored one, and a new entry's secret must be ≥10 characters. - (No longer automated, kept for reference) Group PowerShell impersonation
credential — the group model's
PowerShellImpersonationCredentialId/DistributedImpersonationCredentialId(MVC-only, viaGroups/GetOne→Groups/SaveWithValidate, set-at-create-time only) can pin a per-group remote-PowerShell deploy identity. The role's former feature for it was removed 2026-07-24: a workgroup machine is reached as the logged-in API identity instead (local-account login +enableNtlm+ TrustedHosts), so no per-group identity is needed. The implementation survives in git history.
Deliberately not automated
api/epic/*— Interconnect clinical passthrough, unrelated to deployment config.api/distributed/v1/*— Satellite/agent-facing (auth, downloads, ping, versioning, printing); called by agents, not by us.
Reproducing the scan
The scan is host-local (the control node cannot reach the Kuiper host's 443 in the IRE environment). Run it over WinRM against the target:
# On the Kuiper host — list the public API controllers
Get-ChildItem 'C:\Epic\ECSM Tools\Web\bin' -Filter *.dll | ForEach-Object {
$t = [Text.Encoding]::Unicode.GetString([IO.File]::ReadAllBytes($_.FullName))
[regex]::Matches($t, '[A-Za-z][A-Za-z0-9]{2,60}ApiController') | ForEach-Object { $_.Value }
} | Sort-Object -Unique