Skip to main content

Apps

Manage containerized applications on the Strongly platform. Deploy, scale, start, stop, and monitor your apps programmatically.

All endpoints require a valid API key with the appropriate apps:* scope.

Base URL: https://<your-instance>/api/v1


App Object​

{
"_id": "app-dk8s1o8gwr03",
"name": "orders-ui",
"displayName": "Orders",
"description": "Order tracking front end",
"status": "running",
"url": "/apps/app-dk8s1o8gwr03/view",
"instances": 1,
"cpu": "0.5",
"memory": "1GB",
"disk": "10GB",
"environmentVariables": { "LOG_LEVEL": "info" },
"addons": ["addon-kheofh2erh"],
"dataSources": [],
"aiModels": [],
"workflows": [],
"volumes": ["vol_def001"],
"tags": ["orders"],
"healthCheck": { "enabled": false, "path": "/health", "timeout": 30 },
"permissions": { "isPublic": false, "allowedUsers": [] },
"owner": "okRpaKPh8B9asdKb2",
"organizationId": "org-acme-1a2b3c",
"createdAt": "2026-09-23T19:13:40.840Z",
"updatedAt": "2026-09-23T19:16:20.635Z"
}
FieldDescription
_idApp ID
statusdraft (created, not deployed yet), building, deploying, running, stopped, error
platformUpdatePendingtrue while the running app needs a restart to run the current platform version: the platform could not restart it after an update (the deployer's restart was refused). POST /apps/:id/restart applies it; absent otherwise. See Platform Updates
instancesNumber of replicas the app runs
environmentIdThe runtime environment the app runs in; the app runs at that environment's size. Absent or custom when the size is typed
cpu, memory, diskThe size the app runs at when it has no environment (disk optional). A size that was never set is absent: there is no default
gpu, gpuTypeGPU count and type, present only when set
useSpot, spotFallbackWhether the app runs on spot capacity, and whether it then falls back to on-demand when no spot is available
environmentVariablesYour environment variables (returned by GET /apps/:id only, never in a list)
addons, dataSources, aiModels, mlModels, workflows, featureStores, agentsIDs of the services the app connects to; they reach the app through STRONGLY_SERVICES
volumesIDs of the volumes the app mounts (not in STRONGLY_SERVICES: their files are mounted under /volumes)
permissionsisPublic and allowedUsers; change them through the app's permissions
homeAppEnabledWhether users may be bound to this app as their home app (the only thing they see after signing in)
authThe app's branded sign-in settings (GET /apps/:id only): enabled, slug, signupEnabled, access, allowedDomains, hasLogo and, while enabled, pages (signIn, signUp, forgotPassword, signOut URLs). null when never set. Change them with PATCH /apps/:id/auth

An app has no default size. It runs at exactly the size it is given: its environment's size, or the cpu and memory typed for it. Deploy and restart refuse an app with neither.


GET /api/v1/apps​

List all apps accessible to the authenticated user.

Scope: apps:read

Query Parameters​

ParameterTypeRequiredDefaultDescription
statusstringNo--Filter by status, e.g. running, stopped
environmentIdstringNo--Filter by the runtime environment the app runs in
qstringNo--Search by app name or description
limitintegerNo50Maximum number of results to return
cursorstringNometa.nextCursor of the previous page; omit for the first page
sortstringNo-createdAtSort field; prefix with - for descending, e.g. name, -updatedAt

Response 200 OK​

{
"data": [ { "_id": "app-dk8s1o8gwr03", "name": "orders-ui", "status": "running", "cpu": "0.5", "memory": "1GB", "...": "..." } ],
"meta": { "total": 1, "limit": 50, "nextCursor": null, "requestId": "..." }
}

Each item is an App Object without its build details, thumbnail or environment variables; read those with GET /apps/:id.


POST /api/v1/apps​

Create an app and, when you give it a source, start building it. An app builds from:

  • a volume: a folder of a volume's code, as last synced. This is the usual path for an app built in a workspace: the workspace mounts each volume's code at /volumes/<scope>/<volume name>/code, and its Sync (POST /api/v1/workspaces/:id/sync) commits and pushes that code to the volume.
  • a GitHub repository branch, cloned with one of your GitHub SSH keys (GET /api/v1/me/github-ssh-keys).
  • an uploaded archive: use POST /api/v1/apps/import instead.

With no source the app is recorded as draft. Poll GET /api/v1/apps/:id/build-status until the build is completed, then deploy it.

Give the app its size now or before you deploy it: an environmentId (the app runs at that environment's size), or cpu and memory (disk optional). There is no default size.

Scope: apps:write

Request Body​

FieldTypeRequiredDescription
namestringYesApp name
descriptionstringNoDescription
displayNamestringNoName shown in the UI
bundleSourceTypestringNovolume or github
bundleSourceobjectWith bundleSourceTypevolume: { "volumeId", "folderPath"? }, where folderPath is the folder of the volume's code holding strongly.manifest.yaml (omit it for the whole code). github: { "repoUrl", "branch", "sshKeyId", "subdirectory"? }, where repoUrl is the SSH form (git@github.com:owner/repo.git)
instancesintegerNoNumber of replicas, 1 to 10
cpustringNoCPU cores, e.g. "0.5", "2", "500m"
memorystringNoMemory, e.g. "1GB", "512MB"
diskstringNoDisk, e.g. "10GB"
gpustringNoNumber of GPUs, e.g. "1"
gpuTypestringNoGPU type when gpu is set, e.g. "nvidia-t4"
useSpotbooleanNoRun the app on spot capacity (cheaper; it can be reclaimed, and the app restarts on a new node). Default false
spotFallbackbooleanNoWith useSpot, fall back to on-demand capacity when no spot is available (default true); false waits for spot
environmentIdstringNoRuntime environment the app runs in (and at the size of)
environmentVariablesobjectNoEnvironment variables, name to value
addons, dataSources, aiModels, mlModels, workflows, featureStores, agentsstring[]NoIDs of the services to connect (add-ons, data sources, AI Gateway models, model registry models, deployed workflows, feature stores, deployed agents), passed to the app in STRONGLY_SERVICES
volumesstring[]NoIDs of the volumes the app mounts. A project's volume you have not shared mounts at /volumes/local/<name>; every shared volume, yours included, at /volumes/shared/<name>. code/ is read-only (the code as last synced); data/ is read and write, each written file saved to the volume as a new version. Each must be a volume the app's owner may use. An app with volumes needs a disk: disk, or an environmentId whose environment has one
tagsstring[]NoTags

Any other field is refused with 400.

curl -X POST "https://<your-instance>/api/v1/apps" \
-H "X-API-Key: $STRONGLY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "orders-ui", "cpu": "0.5", "memory": "1GB",
"bundleSourceType": "volume",
"bundleSource": {"volumeId": "<volume id>", "folderPath": "apps/orders-ui"}}'

A volume source needs access to use the volume; a volume you cannot use is refused with 403.

Response 201 Created​

The new app, with its build (buildId, status).


POST /api/v1/apps/import​

Create a new app and optionally attach a deployment bundle in a single multipart/form-data request. Either a name field or a file must be provided.

Scope: apps:write

Request (multipart/form-data)​

FieldTypeRequiredDescription
filefileNoZip bundle containing the app source
namestringNoApp name (required if no file uploaded)
descriptionstringNoApp description
frameworkstringNoBuild hint, e.g. react, express, flask; usually inferred from the bundle
runtimestringNoBuild hint, e.g. node, python
environmentstringNoEnvironment variables as a JSON object string
resourcesstringNoThe app size as a JSON object string with any of cpu, memory, disk, gpu, gpuType, instances, e.g. {"cpu": "0.5", "memory": "1GB"}
metadatastringNoOther app fields (those of POST /apps) as a JSON object string

A JSON field that is not a JSON object is refused with 400.

Response 201 Created​

The new app, with its build (buildId, status).


GET /api/v1/apps/:id​

Retrieve a single app by its ID, with its branded sign-in settings and, on request, its usage summary.

Scope: apps:read

Path Parameters​

ParameterTypeRequiredDescription
idstringYesThe app ID

Query Parameters​

ParameterTypeRequiredDefaultDescription
includestringNo--analytics adds data.analytics: { range, summary } with the summary of GET /apps/:id/analytics
rangestringNo30dThe analytics range: 7d, 30d or 90d

Response 200 OK​

The App Object, in data, with auth (see the object) and, with include=analytics, analytics.


PATCH /api/v1/apps/:id​

Change an app's definition. Only the fields sent change; list fields replace the whole set. The running app picks up a change when it is redeployed.

Scope: apps:write

Path Parameters​

ParameterTypeRequiredDescription
idstringYesThe app ID

Request Body​

name, description, displayName, tags, environmentVariables, addons, dataSources, aiModels, mlModels, workflows, featureStores, agents, volumes, cpu, memory, disk, instances, useSpot, spotFallback, environmentId, environmentVersion, port, healthCheck, buildCommand, startCommand and thumbnail. A field outside this list is refused with 400 and the list of updatable fields. volumes replaces the set the app mounts and applies at its next deploy, start or restart; an app given volumes without a disk is refused, naming what is missing.

Sizes are checked as given: cpu must be a number of cores ("0.5", "500m"), memory and disk sizes ("1GB", "512MB"), and instances a whole number from 1 to 10.

{ "cpu": "1", "memory": "2GB", "instances": 2 }

Response 200 OK​

The updated App Object, in data.

Errors​

StatusCodeWhen
400validation-errorAn unsupported field, or a size that is not a size (the message says which)
403forbiddenYou cannot edit this app
404not-foundNo such app

PUT /api/v1/apps/:id/env​

Set an app's environment variables. The object replaces the app's environment, so send the complete set (read the app first and merge, or variables it already has are dropped). On a running app the change rolls out with no downtime: a new instance starts with the new values and the old one is retired once it is ready. On a stopped app the values are saved and applied at its next start.

Scope: apps:write

Request Body​

FieldTypeRequiredDescription
environmentVariablesobjectYesVariable name to value. Values are strings, numbers or booleans (stored as text); a name must not be empty.
curl -X PUT -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d '{"environmentVariables": {"LOG_LEVEL": "info", "FEATURE_X": "true"}}' \
"$BASE/api/v1/apps/$APP_ID/env"

Response 200 OK​

The updated app (see App Object).

Errors​

StatusErrorWhen
400validation-errorenvironmentVariables is missing or not an object, or an entry has an empty name or a value that is not a string, number or boolean (each named)

Sharing​

Who can reach a app is the platform's one sharing shape: its owner, its members (role editor can use and change it, user can only use it) and its visibility (public: every user can find and use it; in a multi-tenant deployment, every user of its organization). Changing it stays with its owner and editors.

MethodPathDoesScope
GET/api/v1/apps/:id/permissionsOwner, members (userId, role) and visibilityapps:read
POST/api/v1/apps/:id/permissions/membersShare with a user: { "userId", "role": "editor" | "user" }apps:write
DELETE/api/v1/apps/:appId/permissions/members/:userIdStop sharing with a userapps:write
PATCH/api/v1/apps/:id/permissions{ "visibility": "public" | "private" }apps:write

Each, except a member's removal (204), answers the permissions as they are now:

{
"data": {
"resourceId": "<app id>",
"owner": "<user id>",
"members": [{ "userId": "<user id>", "role": "user" }],
"visibility": "private"
},
"meta": { "requestId": "req_abc123" }
}

DELETE /api/v1/apps/:id​

Permanently delete an app and all associated resources.

Scope: apps:write

Path Parameters​

ParameterTypeRequiredDescription
idstringYesThe app ID

Response 204 No Content​

No response body.


POST /api/v1/apps/:id/deploy​

Deploy or redeploy the app from its current definition: its built image, size, instances, environment variables and connections. The body takes no options; change the app with PATCH /apps/:id first.

The app runs at exactly its size: its environment's, or the cpu and memory set on it. An app with neither is refused, and nothing is stopped or started.

Scope: apps:deploy

Path Parameters​

ParameterTypeRequiredDescription
idstringYesThe app ID

Response 200 OK​

The app, as GET /api/v1/apps/:id answers it. The deployment has started; poll GET /apps/:id/status until the app is running.

Errors​

StatusCodeWhen
400validation-errorThe app has no size (the message names what is missing), or a body was sent
402payment-requiredA budget blocks the launch
403governance-blockedGovernance requirements for going live are not met
409invalid-stateThe image has not finished building; poll GET /apps/:id/status and deploy once it has

PUT /api/v1/apps/:id/source​

Upload a new bundle for an existing app. This starts the build of the new version and returns right away. Deploy the new version with POST /api/v1/apps/:id/deploy once GET /api/v1/apps/:id/build-status reports the build completed; a deploy while the build is still running is refused. Environment variables and size are set on the app itself (PATCH /api/v1/apps/:id), not on the upload.

Scope: apps:deploy

Path Parameters​

ParameterTypeRequiredDescription
idstringYesThe app ID

Request (multipart/form-data)​

FieldTypeRequiredDescription
filefileYesZip bundle containing the new app source
descriptionstringNoUpdated description

Response 202 Accepted​

The build of the new version has started: the app, with its build.


POST /api/v1/apps/:id/rebuild​

Build a new version of an app from source. With no body it builds from the app's recorded source (its volume folder as last synced, or its GitHub branch as it is now); after changing the code in a workspace, sync the workspace first. With a body it builds from the source given, which becomes the app's source (for example an app first uploaded as an archive, now built from its workspace volume). All configuration is kept, and the running version keeps serving until you deploy the new one.

Scope: apps:deploy

Request Body (optional)​

FieldTypeRequiredDescription
bundleSourceTypestringNovolume or github
bundleSourceobjectWith bundleSourceTypeAs in POST /api/v1/apps

Response 202 Accepted​

The build has started. Poll GET /api/v1/apps/:id/build-status until it is completed, then POST /api/v1/apps/:id/deploy. An app whose current version was an uploaded archive, rebuilt with no body, is refused with 400 (no-source).


POST /api/v1/apps/:id/start​

Start a stopped app.

Scope: apps:deploy

Path Parameters​

ParameterTypeRequiredDescription
idstringYesThe app ID

Response 200 OK​

The app, as GET /api/v1/apps/:id answers it.


POST /api/v1/apps/:id/stop​

Stop a running app. All replicas are scaled to zero.

Scope: apps:deploy

Path Parameters​

ParameterTypeRequiredDescription
idstringYesThe app ID

Response 200 OK​

The app, as GET /api/v1/apps/:id answers it.


POST /api/v1/apps/:id/restart​

Restart all replicas of a running app with a rolling restart.

Scope: apps:deploy

Path Parameters​

ParameterTypeRequiredDescription
idstringYesThe app ID

Response 200 OK​

The app, as GET /api/v1/apps/:id answers it.

A restart can redeploy the app, so an app with no size is refused with 400 before anything is stopped.


GET /api/v1/apps/:id/status​

Get the real-time runtime status of an app.

Scope: apps:read

Path Parameters​

ParameterTypeRequiredDescription
idstringYesThe app ID

Response 200 OK​

{
"data": {
"deployed": true,
"appId": "app_abc123",
"status": "running",
"state": "running",
"replicas": 3,
"readyReplicas": 3,
"appName": "app_abc123",
"deployment": { "...": "..." },
"services": [ { "...": "..." } ],
"pods": [ { "...": "..." } ]
},
"meta": { "requestId": "..." }
}

status is the app's serving state; state is running when every replica is ready, deploying while they come up, and stopped at zero replicas. Before the app's first deploy, data is { "deployed": false, "appId", "status", "buildStatus", "replicas": 0, "message" }, where message says whether to wait for the build, deploy, or fix a failed build.


GET /api/v1/apps/:id/logs​

Retrieve recent logs from the app containers.

Scope: apps:read

Path Parameters​

ParameterTypeRequiredDescription
idstringYesThe app ID

Query Parameters​

ParameterTypeRequiredDefaultDescription
linesintegerNowhole logNumber of most recent log lines to return; omitted, the whole log since the app started
skipNewestintegerNo0How many of the newest lines to skip. With lines, returns the page before them: read a long log from its end with lines=100, then skipNewest=100, skipNewest=200, and so on
sincestringNo--Return logs after this ISO 8601 timestamp
containerstringNo--Target a specific container by name

Response 200 OK​

{
"data": [
{ "timestamp": "2025-01-20T14:22:01Z", "message": "[INFO] Server started on port 3000", "level": "info", "pod": "app-abc-7d9f-x2k", "container": "my-app" },
{ "timestamp": "2025-01-20T14:22:02Z", "message": "[INFO] Connected to database", "level": "info", "pod": "app-abc-7d9f-x2k", "container": "my-app" }
],
"meta": { "requestId": "..." }
}

Each line has the timestamp it was logged at, its message, the level the line states (error, warning, info, debug, or null), and the instance (pod) and container it came from.


GET /api/v1/apps/:id/build-status​

The state of the app's latest image build (pending, building, completed or failed), which is separate from whether the app is running. Check it after a deploy or rebuild.

Scope: apps:read

Response 200 OK​

{ "data": { "buildId": "...", "status": "building", "createdAt": "2026-10-05T13:40:02", "startedAt": "2026-10-05T13:40:05", "elapsedMinutes": 2 } }

Errors​

StatusErrorWhen
404not-foundNo app with this id that you can read
409invalid-stateThe app has never been built

GET /api/v1/apps/:id/build-logs​

The log of the app's latest image build: the place to read why a build failed.

Scope: apps:read

Query Parameters​

ParameterTypeDescription
limitnumberAt most this many lines
levelstringOnly lines of this level
sincestringOnly lines after this time (ISO 8601)

Response 200 OK​

The build's log lines. An app that has never been built answers { "buildId": null, "logs": [], "message": "This app has no build yet - trigger a build/deploy first." }.


GET /api/v1/apps/:id/metrics​

Measure a running app now. Each call takes a fresh measurement (about 6 seconds, including a 5 second network sample). A value that cannot be measured is reported as an error, never as a zero.

Scope: apps:read

Path Parameters​

ParameterTypeRequiredDescription
idstringYesThe app ID

Response 200 OK​

{
"data": {
"cpu": { "usageMillicores": 12.4, "limitMillicores": 500, "percent": 2.5 },
"memory": { "usageBytes": 94371840, "limitBytes": 1073741824, "percent": 8.8 },
"network": { "receiveBytesPerSecond": 1536.2, "transmitBytesPerSecond": 2560.4, "totalBytesPerSecond": 4096.6, "windowSeconds": 5.01 },
"responseTime": { "avgMs": 4.2, "minMs": 2.9, "maxMs": 7.1, "samples": 5, "urlPath": "/" },
"replicas": { "ready": 1, "total": 1, "measured": 1 },
"containers": [
{ "name": "my-app", "pod": "app-abc-7d9f-x2k", "role": "app", "cpuMillicores": 12.4, "cpuLimitMillicores": 500,
"cpuPercent": 2.5, "memoryBytes": 94371840, "memoryLimitBytes": 1073741824, "memoryPercent": 8.8 },
{ "name": "egress-proxy", "pod": "app-abc-7d9f-x2k", "role": "platform", "cpuMillicores": 1.1, "cpuLimitMillicores": 200,
"cpuPercent": 0.6, "memoryBytes": 31457280, "memoryLimitBytes": 134217728, "memoryPercent": 23.4 }
],
"platformContainersTotal": { "cpuMillicores": 1.1, "memoryBytes": 31457280, "count": 1 },
"measuredAt": "2026-09-23T18:30:00.000000+00:00"
},
"meta": { "requestId": "..." }
}
FieldDescription
cpu, memoryUsage of your app's container, summed over its running replicas, against the size you set.
networkReceive and transmit throughput of the app's instances in bytes per second over the sampling window.
responseTimeRound trip time of the app answering an HTTP request from the platform.
replicasReady and desired replicas, and how many were measured.
containersEvery container of every replica, with its usage and size. role is app for yours and platform for the containers the platform runs beside it.
platformContainersTotalCombined usage of the platform containers.

Errors​

StatusCodeWhen
409invalid-stateThe app is not running.
500internal-errorA measurement could not be taken; the message gives the reason.

PATCH /api/v1/apps/:id/auth​

Set the app's branded sign-in and sign-up: its own public sign-in, sign-up, forgot-password and sign-out pages at https://<your-platform>/<slug>/, with the app's logo and name. People who sign up or sign in there become app users whose home app is this app: they land in the app and never see the platform. Enabling it also turns on Home App. Only the fields sent change.

Scope: apps:write

Path Parameters​

ParameterTypeRequiredDescription
idstringYesThe app ID

Request Body​

FieldTypeRequiredDescription
enabledbooleanNoTurn the branded pages on or off
slugstringNoThe URL name of the pages: lowercase letters, digits and hyphens; unique across apps; the platform's own paths are reserved. Defaults to one made from the app's name
signupEnabledbooleanNoWhether the public sign-up page is open (default true)
accessstringNoinstant (default): a new account is usable at once, after email verification when the platform has mail configured. approval: an administrator of the app activates each new account
allowedDomainsstring[]NoEmail domains a sign-up must belong to, subdomains included. Empty allows any address
logostringNoA data:image/... URL under 200KB (PNG, JPG or SVG) shown on the pages; "" for none
{ "enabled": true, "slug": "client-portal", "access": "approval", "allowedDomains": ["example.com"] }

Response 200 OK​

{
"data": {
"appId": "app-...",
"auth": {
"enabled": true, "slug": "client-portal", "signupEnabled": true, "access": "approval",
"allowedDomains": ["example.com"], "hasLogo": false,
"pages": {
"signIn": "https://<your-platform>/client-portal/",
"signUp": "https://<your-platform>/client-portal/signup",
"forgotPassword": "https://<your-platform>/client-portal/forgot-password",
"signOut": "https://<your-platform>/client-portal/sign-out"
}
}
}
}

Errors​

StatusCodeWhen
400validation-errorAn unknown field, a slug that is not lowercase letters, digits and hyphens or is a platform path, or a logo that is not a small image
409conflictAnother app already uses that slug
403forbiddenYou cannot configure this app
404not-foundNo such app

GET /api/v1/apps/:id/users​

The app's users: everyone who signed up through its branded pages or is bound to it as their home app.

Scope: apps:read

Query Parameters​

ParameterTypeRequiredDefaultDescription
statusstringNoallactive, pending (waiting for approval) or archived
qstringNo--Search by name or email
sortstringNo-createdAtname, email or createdAt; prefix with - for descending
limitintegerNo50Maximum number of results
cursorstringNometa.nextCursor of the previous page; omit for the first page

Response 200 OK​

{
"data": [
{ "userId": "u-...", "name": "Dana Reyes", "email": "dana@example.com", "emailVerified": true,
"status": "pending", "role": "user", "homeApp": true, "freeAccess": false,
"signedUp": "2026-10-02T15:12:00.000Z", "lastSignIn": null }
],
"meta": { "total": 1, "limit": 50, "nextCursor": null, "requestId": "..." }
}

status is active, pending (the account waits for an administrator, or was deactivated) or archived. role is the user's role on the app (user or admin). homeApp says whether this app is their home app. Sessions and time in the app are on GET /apps/:id/analytics/users.


GET /api/v1/apps/:appId/users/:id​

One of the app's users, as the users list shows them: userId, name, email, emailVerified, status, role, freeAccess, homeApp, signedUp, lastSignIn. Someone who is not a user of the app is 404 not-found.

Scope: apps:read


PATCH /api/v1/apps/:appId/users/:id​

Change one of the app's users. Send active and/or freeAccess:

  • active: true activates an account waiting for approval, or one that was deactivated: it can sign in.
  • active: false deactivates it: it is signed out now and cannot sign in until activated again. The account and its data are kept.
  • freeAccess: true gives the user free access on an app that charges for access: they are never asked for a plan. false asks them for a plan again.

Scope: apps:write

Request Body​

{ "active": true }

Response 200 OK​

The user, as GET /apps/:id/users lists them.


POST /api/v1/apps/:appId/users/:id/reset-password​

Email the user a password reset link that opens the app's branded reset page.

Scope: apps:write

Response 200 OK​

The user the link was sent to, as the users list shows them.


DELETE /api/v1/apps/:appId/users/:id​

Remove the user from the app: signed out, no longer an app user of it and no longer bound to it as their home app. Their platform account is kept.

Scope: apps:write

Response 200 OK​

{ "data": { "appId": "app-...", "userId": "u-...", "removed": true } }

GET /api/v1/apps/:id/analytics​

The app's usage over a range: the platform's own count of the requests it proxied to the app, per signed-in user and minute, turned into sessions.

Scope: apps:read

Query Parameters​

ParameterTypeRequiredDefaultDescription
rangestringNo30d7d, 30d or 90d (days ending today, UTC)

Response 200 OK​

{
"data": {
"range": "30d",
"sessionGapMinutes": 30,
"summary": {
"uniqueUsers": 12, "newUsers": 4, "returningUsers": 8,
"sessions": 57, "requests": 1830, "sessionsPerUser": 4.75,
"avgSessionMinutes": 11.3, "medianSessionMinutes": 7.0,
"activeDays": 19, "lastUsedAt": "2026-10-02T15:16:00+00:00"
},
"daily": [ { "day": "2026-09-03T00:00:00+00:00", "activeUsers": 3, "newUsers": 1, "returningUsers": 2, "sessions": 5 } ],
"sessionLengths": [ { "bucket": "Under 1 min", "sessions": 9 }, { "bucket": "1 to 5 min", "sessions": 20 } ],
"startsByWeekdayHour": [ [0, 0, 1] ]
}
}
FieldDescription
sessionGapMinutesMinutes of inactivity after which a user's next request starts a new session
summaryUnique, new (first ever seen in the range) and returning users; sessions and requests; sessions per user; average and median session length in minutes; days with any use; when the app was last used
dailyOne entry per day of the range
sessionLengthsSessions per length bucket
startsByWeekdayHourSession starts by weekday (Monday first) and UTC hour: seven arrays of 24 counts

GET /api/v1/apps/:id/analytics/users​

Who used the app over the range, one row per user.

Scope: apps:read

Query Parameters​

ParameterTypeRequiredDefaultDescription
rangestringNo30d7d, 30d or 90d
qstringNo--Search by name or email
sortstringNo-minutesname, sessions, minutes, requests or lastSeen; prefix with - for descending
limitintegerNo50Maximum number of results
cursorstringNometa.nextCursor of the previous page; omit for the first page

Response 200 OK​

{
"data": [
{ "userId": "u-...", "name": "Dana Reyes", "email": "dana@example.com",
"sessions": 9, "minutes": 84.5, "requests": 311, "lastSeen": "2026-10-02T15:16:00+00:00" }
],
"meta": { "total": 12, "limit": 50, "nextCursor": null, "requestId": "..." }
}

The paid-access endpoints need the Stripe App Payments plugin installed and on for the organization (installed once from the Marketplace by an administrator, or an organization developer in multi-tenant); otherwise they answer 409 with code plugin-not-installed.

GET /api/v1/apps/:id/paid-access​

The app's paid-access settings. This is the app owner's own Stripe account charging the app's users; it is separate from Strongly billing and credits. It says whether a plan is required, the Stripe account on file (mode and last four characters of the key; the key itself is never returned), the webhook URL to register in Stripe, grace days and the plans on offer. paidAccess is null when it was never set up.

Scope: apps:read

Response 200 OK​

{
"data": {
"appId": "app-...",
"paidAccess": {
"enabled": true, "stripeMode": "live", "keyLastFour": "a1b2", "hasWebhookSecret": true,
"webhookUrl": "https://<your-platform>/api/stripe/webhook/app/app-...",
"graceDays": 3,
"plans": [
{ "planId": "pro", "name": "Pro", "description": "", "kind": "individual", "stripePriceId": "price_...", "minSeats": 1, "trialDays": 14 },
{ "planId": "team", "name": "Team", "description": "", "kind": "team", "stripePriceId": "price_...", "minSeats": 2, "trialDays": 0 }
],
"updatedAt": "2026-10-02T16:00:00.000Z"
}
}
}

PATCH /api/v1/apps/:id/paid-access​

Set up or change paid access with the app owner's own Stripe account (separate from Strongly billing and credits). Only the fields sent change; keys left out stay as they are.

Scope: apps:write

Request Body​

FieldTypeRequiredDescription
stripeSecretKeystringFirst timesk_live_... or sk_test_...; stored encrypted
stripeWebhookSecretstringNowhsec_... of the webhook endpoint registered in Stripe for the app's webhook URL; stored encrypted
graceDaysintegerNoDays access continues after a failed payment, 0 to 90 (default 3)
enabledbooleanNoRequire a plan from the app's users. Needs branded sign-in (PATCH /apps/:id/auth) and at least one plan

Response 200 OK​

{ "appId", "paidAccess" } as in GET /apps/:id/paid-access.

Errors​

StatusCodeWhen
400validation-errorAn unknown field, a key that is not sk_..., a secret that is not whsec_..., grace days out of range, or no key on first set-up
409invalid-stateEnabling without branded sign-in

PUT /api/v1/apps/:appId/paid-access/plans/:id​

Create or replace a plan.

Scope: apps:write

Request Body​

FieldTypeRequiredDescription
namestringYesShown to users
kindstringYesindividual (one person pays for themself) or team (one payer, seats for members)
stripePriceIdstringYesprice_... of a recurring price in the owner's Stripe account
descriptionstringNoWhat the plan includes
minSeatsintegerNoTeam plans: the fewest seats a team may have (default 1)
trialDaysintegerNoFree trial days at checkout (default 0)

Response 200 OK​

{ "appId", "paidAccess" } with the plans as they now are.


DELETE /api/v1/apps/:appId/paid-access/plans/:id​

Stop offering a plan. Subscriptions already on it continue.

Scope: apps:write

Response 200 OK​

{ "appId", "paidAccess" } with the remaining plans. 404 when there is no such plan.


GET /api/v1/apps/:id/paid-access/subscriptions​

Every subscription of the app's paid access (the app owner's Stripe account, not Strongly billing).

Scope: apps:read

Query Parameters​

ParameterTypeRequiredDefaultDescription
statusstringNoallactive, grace (a payment failed; still served), suspended or canceled
qstringNo--Search by payer name or email
sortstringNo-createdAtcreatedAt, status, seats or currentPeriodEnd; prefix with - for descending
limitintegerNo50Maximum number of results
cursorstringNometa.nextCursor of the previous page; omit for the first page

Response 200 OK​

{
"data": [
{ "_id": "...", "planId": "team", "planName": "Team", "kind": "team", "status": "active", "stripeStatus": "active",
"payer": { "userId": "u-...", "name": "Dana Reyes", "email": "dana@example.com" },
"seats": 5, "membersCount": 3, "invitesCount": 1,
"currentPeriodEnd": "2026-11-02T00:00:00.000Z", "cancelAtPeriodEnd": false,
"stripeSubscriptionId": "sub_...", "createdAt": "2026-10-02T16:00:00.000Z" }
],
"meta": { "total": 1, "limit": 50, "nextCursor": null, "requestId": "..." }
}