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"
}
| Field | Description |
|---|---|
_id | App ID |
status | draft (created, not deployed yet), building, deploying, running, stopped, error |
platformUpdatePending | true 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 |
instances | Number of replicas the app runs |
environmentId | The runtime environment the app runs in; the app runs at that environment's size. Absent or custom when the size is typed |
cpu, memory, disk | The 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, gpuType | GPU count and type, present only when set |
useSpot, spotFallback | Whether the app runs on spot capacity, and whether it then falls back to on-demand when no spot is available |
environmentVariables | Your environment variables (returned by GET /apps/:id only, never in a list) |
addons, dataSources, aiModels, mlModels, workflows, featureStores, agents | IDs of the services the app connects to; they reach the app through STRONGLY_SERVICES |
volumes | IDs of the volumes the app mounts (not in STRONGLY_SERVICES: their files are mounted under /volumes) |
permissions | isPublic and allowedUsers; change them through the app's permissions |
homeAppEnabled | Whether users may be bound to this app as their home app (the only thing they see after signing in) |
auth | The 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
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
status | string | No | -- | Filter by status, e.g. running, stopped |
environmentId | string | No | -- | Filter by the runtime environment the app runs in |
q | string | No | -- | Search by app name or description |
limit | integer | No | 50 | Maximum number of results to return |
cursor | string | No | meta.nextCursor of the previous page; omit for the first page | |
sort | string | No | -createdAt | Sort 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/importinstead.
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
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | App name |
description | string | No | Description |
displayName | string | No | Name shown in the UI |
bundleSourceType | string | No | volume or github |
bundleSource | object | With bundleSourceType | volume: { "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) |
instances | integer | No | Number of replicas, 1 to 10 |
cpu | string | No | CPU cores, e.g. "0.5", "2", "500m" |
memory | string | No | Memory, e.g. "1GB", "512MB" |
disk | string | No | Disk, e.g. "10GB" |
gpu | string | No | Number of GPUs, e.g. "1" |
gpuType | string | No | GPU type when gpu is set, e.g. "nvidia-t4" |
useSpot | boolean | No | Run the app on spot capacity (cheaper; it can be reclaimed, and the app restarts on a new node). Default false |
spotFallback | boolean | No | With useSpot, fall back to on-demand capacity when no spot is available (default true); false waits for spot |
environmentId | string | No | Runtime environment the app runs in (and at the size of) |
environmentVariables | object | No | Environment variables, name to value |
addons, dataSources, aiModels, mlModels, workflows, featureStores, agents | string[] | No | IDs 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 |
volumes | string[] | No | IDs 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 |
tags | string[] | No | Tags |
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)
| Field | Type | Required | Description |
|---|---|---|---|
file | file | No | Zip bundle containing the app source |
name | string | No | App name (required if no file uploaded) |
description | string | No | App description |
framework | string | No | Build hint, e.g. react, express, flask; usually inferred from the bundle |
runtime | string | No | Build hint, e.g. node, python |
environment | string | No | Environment variables as a JSON object string |
resources | string | No | The app size as a JSON object string with any of cpu, memory, disk, gpu, gpuType, instances, e.g. {"cpu": "0.5", "memory": "1GB"} |
metadata | string | No | Other 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
| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | Yes | The app ID |
Query Parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
include | string | No | -- | analytics adds data.analytics: { range, summary } with the summary of GET /apps/:id/analytics |
range | string | No | 30d | The 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
| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | Yes | The 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
| Status | Code | When |
|---|---|---|
400 | validation-error | An unsupported field, or a size that is not a size (the message says which) |
403 | forbidden | You cannot edit this app |
404 | not-found | No 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
| Field | Type | Required | Description |
|---|---|---|---|
environmentVariables | object | Yes | Variable 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
| Status | Error | When |
|---|---|---|
400 | validation-error | environmentVariables 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.
| Method | Path | Does | Scope |
|---|---|---|---|
| GET | /api/v1/apps/:id/permissions | Owner, members (userId, role) and visibility | apps:read |
| POST | /api/v1/apps/:id/permissions/members | Share with a user: { "userId", "role": "editor" | "user" } | apps:write |
| DELETE | /api/v1/apps/:appId/permissions/members/:userId | Stop sharing with a user | apps: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
| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | Yes | The 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
| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | Yes | The 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
| Status | Code | When |
|---|---|---|
400 | validation-error | The app has no size (the message names what is missing), or a body was sent |
402 | payment-required | A budget blocks the launch |
403 | governance-blocked | Governance requirements for going live are not met |
409 | invalid-state | The 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
| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | Yes | The app ID |
Request (multipart/form-data)
| Field | Type | Required | Description |
|---|---|---|---|
file | file | Yes | Zip bundle containing the new app source |
description | string | No | Updated 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)
| Field | Type | Required | Description |
|---|---|---|---|
bundleSourceType | string | No | volume or github |
bundleSource | object | With bundleSourceType | As 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
| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | Yes | The 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
| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | Yes | The 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
| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | Yes | The 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
| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | Yes | The 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
| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | Yes | The app ID |
Query Parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
lines | integer | No | whole log | Number of most recent log lines to return; omitted, the whole log since the app started |
skipNewest | integer | No | 0 | How 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 |
since | string | No | -- | Return logs after this ISO 8601 timestamp |
container | string | No | -- | 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
| Status | Error | When |
|---|---|---|
404 | not-found | No app with this id that you can read |
409 | invalid-state | The 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
| Parameter | Type | Description |
|---|---|---|
limit | number | At most this many lines |
level | string | Only lines of this level |
since | string | Only 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
| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | Yes | The 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": "..." }
}
| Field | Description |
|---|---|
cpu, memory | Usage of your app's container, summed over its running replicas, against the size you set. |
network | Receive and transmit throughput of the app's instances in bytes per second over the sampling window. |
responseTime | Round trip time of the app answering an HTTP request from the platform. |
replicas | Ready and desired replicas, and how many were measured. |
containers | Every container of every replica, with its usage and size. role is app for yours and platform for the containers the platform runs beside it. |
platformContainersTotal | Combined usage of the platform containers. |
Errors
| Status | Code | When |
|---|---|---|
409 | invalid-state | The app is not running. |
500 | internal-error | A 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
| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | Yes | The app ID |
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
enabled | boolean | No | Turn the branded pages on or off |
slug | string | No | The 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 |
signupEnabled | boolean | No | Whether the public sign-up page is open (default true) |
access | string | No | instant (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 |
allowedDomains | string[] | No | Email domains a sign-up must belong to, subdomains included. Empty allows any address |
logo | string | No | A 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
| Status | Code | When |
|---|---|---|
400 | validation-error | An 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 |
409 | conflict | Another app already uses that slug |
403 | forbidden | You cannot configure this app |
404 | not-found | No 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
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
status | string | No | all | active, pending (waiting for approval) or archived |
q | string | No | -- | Search by name or email |
sort | string | No | -createdAt | name, email or createdAt; prefix with - for descending |
limit | integer | No | 50 | Maximum number of results |
cursor | string | No | meta.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: trueactivates an account waiting for approval, or one that was deactivated: it can sign in.active: falsedeactivates it: it is signed out now and cannot sign in until activated again. The account and its data are kept.freeAccess: truegives the user free access on an app that charges for access: they are never asked for a plan.falseasks 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
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
range | string | No | 30d | 7d, 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] ]
}
}
| Field | Description |
|---|---|
sessionGapMinutes | Minutes of inactivity after which a user's next request starts a new session |
summary | Unique, 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 |
daily | One entry per day of the range |
sessionLengths | Sessions per length bucket |
startsByWeekdayHour | Session 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
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
range | string | No | 30d | 7d, 30d or 90d |
q | string | No | -- | Search by name or email |
sort | string | No | -minutes | name, sessions, minutes, requests or lastSeen; prefix with - for descending |
limit | integer | No | 50 | Maximum number of results |
cursor | string | No | meta.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
| Field | Type | Required | Description |
|---|---|---|---|
stripeSecretKey | string | First time | sk_live_... or sk_test_...; stored encrypted |
stripeWebhookSecret | string | No | whsec_... of the webhook endpoint registered in Stripe for the app's webhook URL; stored encrypted |
graceDays | integer | No | Days access continues after a failed payment, 0 to 90 (default 3) |
enabled | boolean | No | Require 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
| Status | Code | When |
|---|---|---|
400 | validation-error | An 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 |
409 | invalid-state | Enabling without branded sign-in |
PUT /api/v1/apps/:appId/paid-access/plans/:id
Create or replace a plan.
Scope: apps:write
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Shown to users |
kind | string | Yes | individual (one person pays for themself) or team (one payer, seats for members) |
stripePriceId | string | Yes | price_... of a recurring price in the owner's Stripe account |
description | string | No | What the plan includes |
minSeats | integer | No | Team plans: the fewest seats a team may have (default 1) |
trialDays | integer | No | Free 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
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
status | string | No | all | active, grace (a payment failed; still served), suspended or canceled |
q | string | No | -- | Search by payer name or email |
sort | string | No | -createdAt | createdAt, status, seats or currentPeriodEnd; prefix with - for descending |
limit | integer | No | 50 | Maximum number of results |
cursor | string | No | meta.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": "..." }
}