Nova Act QA Studio provides a RESTful API for managing test automation workflows, including test execution, OAuth client management, and artifact handling. All endpoints require authentication via AWS Cognito and use OAuth 2.0 scopes for authorization.
Base URL: https://{api-gateway-id}.execute-api.{region}.amazonaws.com/{stage}/
API Version: 1.2.0
- Authentication
- Usecase Management Endpoints
- Template Endpoints
- Application Configuration Endpoints
- Cost Endpoints
- Execution Endpoints
- Wizard Session Endpoints
- Device Farm Endpoints
- Test Suite Endpoints
- OAuth Client Management
- User Management Endpoints
- Error Handling
- Rate Limiting
- Pagination
All API requests must include a valid JWT token in the Authorization header:
Authorization: Bearer <jwt_token>For CI/CD integration and machine-to-machine (M2M) authentication, use the OAuth 2.0 client credentials flow:
Step 1: Create OAuth Client
Create an OAuth client via the web UI or API (requires user authentication):
curl -X POST 'https://api.example.com/api/oauth-clients' \
-H 'Authorization: Bearer <user_jwt_token>' \
-H 'Content-Type: application/json' \
-d '{
"name": "CI/CD Runner - Production",
"teamId": "team-123",
"scopes": ["api/suite.read", "api/suite.write", "api/executions.write"]
}'The client is bound to teamId and acts with your role in that team, capped at Editor.
Response:
{
"client_id": "7abc123def456",
"client_secret": "secret_xyz789...",
"created_date": "2026-02-16T12:00:00Z"
}IMPORTANT: Save the client_secret - it will only be shown once!
Step 2: Obtain Access Token
Use the client credentials to obtain an access token from Cognito:
curl -X POST \
'https://{cognito-domain}.auth.{region}.amazoncognito.com/oauth2/token' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'grant_type=client_credentials' \
-d 'client_id=7abc123def456' \
-d 'client_secret=secret_xyz789...' \
-d 'scope=api/suite.read api/suite.write api/executions.write'Response:
{
"access_token": "eyJraWQiOiJ...",
"expires_in": 3600,
"token_type": "Bearer"
}Step 3: Use Access Token
Include the access token in all API requests:
curl -X POST 'https://api.example.com/usecase/usecase-123/execute' \
-H 'Authorization: Bearer eyJraWQiOiJ...' \
-H 'Content-Type: application/json'- Token Lifetime: Access tokens typically expire after 1 hour (3600 seconds)
- Token Refresh: No refresh tokens are issued for client credentials flow - request a new token when expired
- Token Storage: Store credentials securely using environment variables or secrets manager
- Token Validation: Tokens are validated on every API request
Scopes follow the pattern api/{resource}.{permission}:
| Scope | Description | Access Level |
|---|---|---|
api/admin |
Full access to all resources. Users in the admins group only; never granted to OAuth clients |
Admin |
api/teams.manage |
Manage teams and membership. Never granted to OAuth clients | Admin |
api/usecases.execute |
Trigger use case executions and read secret values | Execute |
api/executions.write |
Modify execution records (status, steps, artifacts, live view) | Write |
api/executions.read |
Read execution data, recordings, trajectories | Read |
api/usecases.write |
Create, update, delete use cases | Write |
api/usecases.read |
Read use case data | Read |
api/suite.write |
Create, update, delete and execute test suites | Write |
api/suite.read |
Read test suites and suite executions | Read |
api/applications.write |
Create, update, delete applications (Team Lead of the owning team only); write application variables, secrets and datasets (Editor+ of the owning team) | Write |
api/applications.read |
Read applications and their variables, secret keys and datasets | Read |
api/templates.write |
Create and edit templates and template steps | Write |
api/templates.read |
Read templates; required alongside api/usecases.* for the usecase↔template endpoints |
Read |
api/oauth-clients.write |
Create, delete, and rotate OAuth clients | Write |
api/oauth-clients.read |
List OAuth clients | Read |
Scope Requirements by Operation (each also needs the team role in Team ownership):
| Operation | Required Scopes | Team action |
|---|---|---|
Execute use case (POST /usecase/{id}/execute) |
api/usecases.execute |
execute |
Read a secret value (GET /usecase/{id}/secrets/{key}/value) |
api/usecases.execute (was api/usecases.read) |
execute |
Trajectory download URL (GET .../trajectory/download-url) |
api/executions.read (was api/executions.write) |
view |
| Trajectory upload URL | api/executions.write |
execute |
| Execute test suite | api/suite.write, api/executions.write |
execute |
| Read test suite | api/suite.read |
view |
| Update execution / step status, upload artifacts | api/executions.write |
execute |
| Delete an execution | api/executions.write |
edit (Editor+) |
| Read execution data | api/executions.read |
view |
POST /generate-s3-url (app_binary, browser_policy) |
api/usecases.write |
edit |
POST /generate-s3-url (html, video) |
api/executions.read |
view |
GET /models |
api/usecases.read |
– |
| Manage OAuth clients | api/oauth-clients.write, api/oauth-clients.read |
Editor+ of the client's team |
Create usecase from template (POST /templates/{id}/apply) |
api/templates.read, api/usecases.write |
edit (on the target application) |
Insert template into usecase (POST /usecase/{id}/import-template) |
api/usecases.write, api/templates.read |
edit |
Update step from template (POST /usecase/{id}/steps/{stepId}/update-from-template) |
api/usecases.write, api/templates.read |
edit |
Check template updates (GET /usecase/{id}/template-updates) |
api/usecases.read, api/templates.read |
view |
Replace a template definition (PUT /templates/{id}) |
api/templates.write |
– |
Delete a template (DELETE /templates/{id}) |
api/templates.write (also unlinks the use-case steps that came from it — no per-usecase check) |
– |
List the tests using a template (GET /templates/{id}/usages) |
api/templates.read |
– |
Read application variables / secret keys / datasets (GET /applications/{id}/{variables|secrets|datasets}) |
api/applications.read |
view (on the application) |
Write application variables / secrets / datasets (POST, PUT, DELETE under /applications/{id}/…) |
api/applications.write |
edit (Editor+ of the owning team or a Full share) |
Best Practice: Grant only the minimum scopes required for your use case.
Scopes alone aren't enough. Every route on a team-owned resource (application, use case, step, variable, hook, header, secret, schedule, execution, execution step, artifact, live view, wizard session, test suite, suite execution) resolves the owning team and checks the caller's role in it. The role rules are in User Management.
- Users get their team roles from Cognito groups (
team-{id}-{team_lead|editor|executor|viewer}). Admins (adminsgroup) bypass team checks. - OAuth clients act with the team role stored on the client (
team_id,team_role, capped ateditor). Clients without a team get 403 on every team-owned resource. - The cloud worker must send
X-QA-Execution-Token(below). - Orphans (no
application_id) are Admin-only.
Status codes added by these checks:
| Status | When |
|---|---|
400 |
Invalid path/body ID; missing teamId or application_id; missing usecaseId on wizard step/command; role: "admin" on a team member endpoint; forbidden scope on an OAuth client; invalid secret_key |
403 |
Missing scope; no role (or too low a role) in the owning team; share level too low; orphan resource for a non-admin; unbound OAuth client; worker without a valid execution token; application update/delete by anyone but the owning Team Lead or an Admin |
404 |
Unknown resource; execution not under the use case in the path (or suite execution not under the suite); wizard session not under the use case; use case hidden by a visibility override; unknown teamId |
409 |
Demoting or deleting the last member of the admins group; creating or renaming an application dataset to a name already used in the application; deleting a dataset that repeat steps still reference; moving a use case into an application while its repeat steps reference datasets (see Application Datasets); PATCH …/executions/{executionId}/status with a different status on an execution that is already terminal (see Update Execution Status) |
| Header | Format | Sent by | Accepted when |
|---|---|---|---|
X-QA-Execution-Token |
{usecase_id}:{execution_id}:{token} |
The qa-studio CLI when QA_STUDIO_EXECUTION_TOKEN is set (the cloud worker) |
The caller is the worker client, the route allows the worker, the token's SHA-256 matches worker_token_hash on that execution, the execution is pending or running, and the path's use case / executionId / suite execution match it |
Header name matching is case-insensitive. Human and CI callers don't need it; a forged header never replaces the team check for them.
| Endpoint | Change |
|---|---|
POST /applications |
teamId (string) required. Caller must be Team Lead of that team or Admin. 404 for an unknown team |
PATCH /applications/{id}, DELETE /applications/{id} |
Team Lead of the owning team or Admin. DELETE returns 204 and also deletes the application's Secrets Manager secrets (by application_id tag); its VARIABLES and DATASET# records go with the partition |
POST /applications/{id}/usecases |
409 {error: "use case references application datasets", usecases: [{usecase_id, name, steps: [{id, instruction}]}]} when a use case being moved in has a repeat step with dataset_id (the id belongs to the application it is leaving). Nothing is written. |
POST /oauth-clients |
teamId (string) required; caller must be Editor+ in it. Response adds team_id, team_role |
POST /usecase |
application_id required (edit on that app); 404 for an unknown app |
POST /import, POST /wizard/start, POST /templates/{id}/apply |
application_id (string) required; edit on that app. The new use case gets application_id and owner_team_id |
POST /usecase/{id}/clone |
Optional application_id (defaults to the source's app); edit on that app |
POST /usecase/{id}/steps |
The use case comes from the path; a different body usecaseId returns 400 |
PATCH /usecase/{id}, PUT /test-suites/{suite_id} |
Changing application_id needs edit on the target app; clearing it is Admin-only |
POST /wizard/{sessionId}/step, /command |
usecaseId (string, required) in the body |
POST /test-suites/{suite_id}/usecases |
Each use case must belong to the suite's owning team (Admins exempt) |
POST /generate-usecase |
screenshot_manifest_key must be {usecase_id}/{session_id}/screenshot_manifest.json for a session the caller can edit |
POST /teams/{id}/members, PATCH /teams/{id}/members/{userId} |
role must be team_lead, editor, executor or viewer; admin returns 400 |
These now return 204 No Content with an empty body: DELETE /applications/{id}, DELETE /oauth-clients/{clientId}, DELETE /usecase/{id}, DELETE /usecase/{id}/steps/{stepId}, DELETE /usecase/{id}/schedule, DELETE /usecase/{id}/secrets, DELETE /usecase/{id}/executions/{executionId}, DELETE .../executions/{executionId}/live-view, DELETE /applications/{id}/usecases/{usecaseId}, DELETE /teams/{id} (the orphaned-app and unassigned-member counts it used to return are kept in the team_delete audit entry).
Usecase management endpoints allow you to create, read, and update test usecases, including enabling step caching for performance optimization.
Create a new test usecase with optional step caching enabled.
Endpoint: POST /usecases
Required Scopes: api/usecases.write or api/admin
Request Body:
{
"name": "Login with valid credentials",
"description": "Test user login flow with valid username and password",
"steps": [
{
"type": "navigation",
"url": "https://example.com/login",
"description": "Navigate to login page"
},
{
"type": "action",
"action": "fill",
"selector": "#username",
"value": "testuser",
"description": "Enter username"
}
],
"enableCache": true
}Request Body Fields:
name(string, required) - Usecase namedescription(string, optional) - Usecase descriptionsteps(array, required) - List of test stepsenableCache(boolean, optional, default: false) - Enable step caching for navigation stepstest_platform(string, optional, default: "web") - Test platform:"web","mobile"or"electron". Electron usecases need nostarting_urland no mobile fields; they can only be executed by the CI runner (qa-studio run --app-path …), never from the UI or a schedule.platform(string, optional) - Mobile platform:"ANDROID"or"IOS"(required when test_platform is "mobile")app_package(string, optional) - Android app package name (e.g.,"com.example.myapp")app_activity(string, optional) - Android app activity (e.g.,"com.example.myapp.MainActivity")bundle_id(string, optional) - iOS bundle identifier (e.g.,"com.example.myapp"); fortest_platform: "electron"the desktop app'sCFBundleIdentifier, verified by the CI runner against the launched.appdevice_arn(string, optional) - Device Farm device ARN (auto-selects newest device if omitted)
Response (201 Created):
{
"id": "01234567-89ab-cdef-0123-456789abcdef",
"name": "Login with valid credentials",
"description": "Test user login flow with valid username and password",
"enableCache": true,
"created_at": "2026-03-03T10:00:00Z",
"updated_at": "2026-03-03T10:00:00Z"
}Error Responses:
400 Bad Request- Missing required fields or invalid data401 Unauthorized- Missing or invalid token403 Forbidden- Insufficient scopes500 Internal Server Error- Server error
Example (cURL):
curl -X POST \
'https://api.example.com/usecases' \
-H 'Authorization: Bearer <token>' \
-H 'Content-Type: application/json' \
-d '{
"name": "Login with valid credentials",
"description": "Test user login flow",
"steps": [...],
"enableCache": true
}'Example (Python):
import requests
response = requests.post(
'https://api.example.com/usecases',
headers={
'Authorization': f'Bearer {access_token}',
'Content-Type': 'application/json'
},
json={
'name': 'Login with valid credentials',
'description': 'Test user login flow',
'steps': [...],
'enableCache': True
}
)
data = response.json()
usecase_id = data['id']
print(f"Usecase created: {usecase_id} (cache enabled: {data['enableCache']})")Retrieve a usecase by ID, including cache status and metadata.
Endpoint: GET /usecases/{id}
Path Parameters:
id(string, required) - Usecase ID
Required Scopes: api/usecases.read or api/admin
Response (200 OK):
{
"id": "01234567-89ab-cdef-0123-456789abcdef",
"name": "Login with valid credentials",
"description": "Test user login flow with valid username and password",
"enableCache": true,
"created_at": "2026-03-03T10:00:00Z",
"updated_at": "2026-03-03T10:15:00Z",
"steps": [
{
"id": "step-001",
"type": "navigation",
"url": "https://example.com/login",
"description": "Navigate to login page"
}
]
}Response Fields:
id- Usecase unique identifiername- Usecase namedescription- Usecase descriptionenableCache- Whether step caching is enabledcreated_at- ISO8601 timestamp of creationupdated_at- ISO8601 timestamp of last updatesteps- List of test steps
Error Responses:
401 Unauthorized- Missing or invalid token403 Forbidden- Insufficient scopes404 Not Found- Usecase not found500 Internal Server Error- Server error
Example (cURL):
curl -X GET \
'https://api.example.com/usecases/01234567-89ab-cdef-0123-456789abcdef' \
-H 'Authorization: Bearer <token>'Example (Python):
import requests
response = requests.get(
f'https://api.example.com/usecases/{usecase_id}',
headers={'Authorization': f'Bearer {access_token}'}
)
data = response.json()
print(f"Usecase: {data['name']}")
print(f"Cache enabled: {data['enableCache']}")Update an existing usecase, including enabling or disabling step caching.
Endpoint: PATCH /usecases/{id}
Path Parameters:
id(string, required) - Usecase ID
Required Scopes: api/usecases.write or api/admin
Request Body:
{
"name": "Login with valid credentials (updated)",
"description": "Updated description",
"enableCache": true
}Request Body Fields (all optional):
name(string) - Updated usecase namedescription(string) - Updated descriptionsteps(array) - Updated test stepsenableCache(boolean) - Enable or disable step caching
Response (200 OK):
{
"id": "01234567-89ab-cdef-0123-456789abcdef",
"name": "Login with valid credentials (updated)",
"description": "Updated description",
"enableCache": true,
"updated_at": "2026-03-03T10:30:00Z"
}Error Responses:
400 Bad Request- Invalid data401 Unauthorized- Missing or invalid token403 Forbidden- Insufficient scopes404 Not Found- Usecase not found500 Internal Server Error- Server error
Example (cURL):
curl -X PATCH \
'https://api.example.com/usecases/01234567-89ab-cdef-0123-456789abcdef' \
-H 'Authorization: Bearer <token>' \
-H 'Content-Type: application/json' \
-d '{
"enableCache": true
}'Example (Python):
import requests
response = requests.patch(
f'https://api.example.com/usecases/{usecase_id}',
headers={
'Authorization': f'Bearer {access_token}',
'Content-Type': 'application/json'
},
json={'enableCache': True}
)
data = response.json()
print(f"Cache enabled: {data['enableCache']}")Retrieve all steps for a usecase, including cache metadata for cached steps.
Endpoint: GET /usecases/{id}/steps
Path Parameters:
id(string, required) - Usecase ID
Required Scopes: api/usecases.read or api/admin
Response (200 OK):
{
"usecase_id": "01234567-89ab-cdef-0123-456789abcdef",
"steps": [
{
"id": "step-001",
"sort": 1,
"type": "navigation",
"url": "https://example.com/login",
"description": "Navigate to login page",
"cacheable": true
},
{
"id": "step-002",
"sort": 2,
"type": "action",
"action": "fill",
"selector": "#username",
"value": "testuser",
"description": "Enter username",
"cacheable": false
}
],
"cachedSteps": "[{\"action\":\"goto\",\"url\":\"https://example.com/login\",\"options\":{\"waitUntil\":\"networkidle\"}}]",
"cacheLastUpdated": "2026-03-03T10:15:00Z"
}Response Fields:
usecase_id- Usecase unique identifiersteps- List of test steps with metadatacacheable- Whether this step type can be cached (navigation steps only)
cachedSteps- JSON string of cached Playwright actions (null if no cache)cacheLastUpdated- ISO8601 timestamp when cache was last built (null if no cache)
Error Responses:
401 Unauthorized- Missing or invalid token403 Forbidden- Insufficient scopes404 Not Found- Usecase not found500 Internal Server Error- Server error
Example (cURL):
curl -X GET \
'https://api.example.com/usecases/01234567-89ab-cdef-0123-456789abcdef/steps' \
-H 'Authorization: Bearer <token>'Example (Python):
import requests
import json
response = requests.get(
f'https://api.example.com/usecases/{usecase_id}/steps',
headers={'Authorization': f'Bearer {access_token}'}
)
data = response.json()
print(f"Total steps: {len(data['steps'])}")
if data['cachedSteps']:
cached_actions = json.loads(data['cachedSteps'])
print(f"Cached actions: {len(cached_actions)}")
print(f"Cache last updated: {data['cacheLastUpdated']}")
else:
print("No cache available")Step caching is native trajectory replay done by the runner (qa-studio run). Fields (snake_case, as returned):
| Field | Type | Default | Where | Description |
|---|---|---|---|---|
enable_cache |
boolean | false | use case (POST/PATCH/GET), copied to the execution record | Step caching setting of the use case |
trajectory_s3_key |
string | null | STEP# / EXECUTION_STEP#, GET /usecase/{id}/steps |
S3 key of the recorded trajectory a navigation step replays. Drives the UI "Cached" badge |
trajectory_last_updated |
string (ISO 8601) | null | same | When the trajectory was recorded |
cached_steps, cache_last_updated |
string / string | null | same | Deprecated. Written only by the removed bbox step cache; still returned for one release, then dropped |
Replay:
- A successful navigation step records its trajectory through
POST …/steps/{stepId}/trajectory/upload-url(trajectory_s3_key/trajectory_last_updatedare set on the STEP record). - Later runs download it (
GET …/trajectory/download-url) and replay it instead of calling the model. - A failed replay falls back to Nova Act and clears the step's fields (
clear_cache_fieldson the step-status call).
Invalidation: a failed execution clears every trajectory of the use case; switching enable_cache from true to
false does the same.
Fields specific to steps of type network_assertion. All fields except network_url_pattern are optional. Defaults match the worker-side fallback behaviour.
Request-side fields:
| Field | Type | Default | Description |
|---|---|---|---|
network_url_pattern |
string | — (required) | Playwright glob pattern identifying the request, e.g. **/api/users |
network_method |
string | empty | Expected HTTP verb: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS. Empty means no method check. |
network_request_body |
string (JSON) | null | Expected request body, interpreted per network_body_match_type |
network_body_match_type |
string | exact |
One of exact, subset, schema. See "Match Types" below. |
Mock response fields:
| Field | Type | Default | Description |
|---|---|---|---|
network_mock_response |
string (JSON) | null | {"status": …, "body": …, "headers": …}. When set, the matching request is intercepted. |
network_mock_passthrough |
boolean | false |
If true, fetch the real response and merge network_mock_response fields over it. If false, the real request is never sent. |
Response-side assertion fields:
| Field | Type | Default | Description |
|---|---|---|---|
network_response_status |
integer | null | Exact-match expected HTTP status code. Must be in [100, 599] when set. |
network_response_body |
string (JSON) | null | Expected response body, interpreted per network_response_body_match_type |
network_response_body_match_type |
string | subset |
One of subset or schema. exact is not permitted on the response side because response payloads frequently contain non-deterministic values. |
Timing:
| Field | Type | Default | Description |
|---|---|---|---|
network_timeout |
integer | 15 |
Seconds to wait for a matching request, capped at 120. |
Match Types:
exact— parsed-JSON equality between expected and captured body. Request side only.subset— every key/value in the expected template must be present in the captured body with matching values. Extra keys are ignored. Arrays match element-by-element; lengths must match.schema— the expected body is a JSON Schema Draft 2020-12 document. The captured body is validated against the schema. External$ref(http/https/file) is rejected; only local-pointer refs (#/...) are allowed.
Size limits:
Request body, response body, mock response, and schema documents are each capped in bytes. The default is 1 MiB (1_048_576) on both server and client. Operators can override the server-side cap via the networkAssertionBodyMaxBytes setting in configuration.json, which flows to the worker and Lambda environment as NETWORK_ASSERTION_BODY_MAX_BYTES. The CLI uses a fixed 1 MiB default for pre-submit validation and relies on the server for authoritative enforcement.
The captured response body is always size-checked, even when no response body assertion is configured, so oversized responses surface early.
Security:
- External
$reftargets (http://,https://,file://) in schema documents are rejected at validation time to prevent SSRF and file-read attacks. - The subset matcher refuses JSON nested deeper than 20 levels.
- Captured bodies are truncated to 500 characters in logs. Only a compact match summary (e.g.
method=POST body_match=subset:pass resp_status=201 resp_body=schema:pass) is persisted in execution records. - Route handlers registered via
network_mock_responseare always torn down on step exit, even on Nova Act failure, timeout, or assertion failure.
Caching:
network_assertion steps are intentionally not cached. The Nova Act action portion participates in the usecase's existing caching ladder (trajectory replay / Playwright cache) identically to a navigation step, but the interception and assertion logic always runs fresh. An API contract change must never be masked by a cache hit.
Fields specific to steps of type repeat (a data-driven loop) and to its children. Validation is shared by POST /usecase/{id}/steps, PATCH /usecase/{id}/steps/{step_id} and POST /import; violations return 400 with the joined error list.
| Field | Type | Default | Applies to | Description |
|---|---|---|---|---|
step_type |
string | — | repeat | "repeat" |
instruction |
string | — (required) | repeat | Human label. Must be unique among the repeat steps of the usecase; repeat_datasets / --dataset-file address the repeat by it. |
dataset_id |
string | absent | repeat | Id of an application dataset of the usecase's application. Exactly one of dataset_id or repeat_columns + repeat_rows must be present: both → 400 repeat step cannot have both dataset_id and inline rows; neither → 400 repeat step must have either dataset_id or repeat_columns/repeat_rows; unknown id or usecase without an application → 400 dataset not found in this application. Rejected on template steps (400 dataset references (dataset_id) are not supported in templates). |
repeat_columns |
list[string] | — (required unless dataset_id) |
repeat | 1–20 identifiers matching ^[A-Za-z_][A-Za-z0-9_]*$, unique, not one of UniqueID, Time, ExecutionID, CreatedAt, RepeatIndex, RepeatTotal. |
repeat_rows |
list[list[string]] | — (required unless dataset_id) |
repeat | 1–100 rows, each with exactly len(repeat_columns) string cells. json.dumps(columns) + json.dumps(rows) ≤ 200 KB. |
parent_step_id |
string | absent | child | id of a repeat step of the same usecase. The parent must itself have no parent_step_id (one nesting level); a child may not be of type repeat. |
Behaviour:
DELETE /usecase/{id}/steps/{step_id}on a repeat also deletes its children (204 No Content).PATCHthat changes a repeat'sstep_typeto anything else while it still has children →400. Changingrepeat_columnsre-validates the stored rows.POST /templates/{id}/stepsandPATCH /templates/{id}/steps/{step_id}rejectstep_type: "repeat"and anyparent_step_idwith400 "repeat steps are not supported in templates".GET /usecase/{id}/exportnests children under the repeat assteps: [...];POST /importaccepts that shape (and the flatid+parent_step_idshape), assigns new ids and remapsparent_step_id.dataset_idis exported as-is (never inlined); import and clone validate it against the target application (see Dataset References on Repeat Steps).PATCH /usecase/{id}/steps/{step_id}treats the body as naming the source: a body withdataset_idswitches the step to the dataset (stored inline rows are dropped); a body withrepeat_columns+repeat_rowsswitches back to inline (dataset_idis dropped).
Expanded execution steps. The repeat itself produces no execution step. When an execution is created, each child is copied once per row (row-major, children in sort order); sort is renumbered 1..n over the flattened list. GET /usecase/{id}/executions/{execution_id}/steps returns these additional fields on expanded steps:
| Field | Type | Default | Description |
|---|---|---|---|
repeat_step_id |
string | absent | id of the repeat the step came from |
repeat_label |
string | absent | The repeat's instruction |
repeat_row_index |
number | absent | 1-based row index |
repeat_row_total |
number | absent | Row count |
row_variables |
map[string,string] | absent | {column: cell} for the row plus RepeatIndex and RepeatTotal |
trajectory_s3_key, trajectory_last_updated, cached_steps and cache_last_updated are never copied onto expanded steps (trajectory replay is off inside a repeat). A repeat with dataset_id is resolved into columns/rows at execution creation (dataset_ref.py), so expanded steps carry the dataset's row_variables and no dataset_id; a missing dataset fails the execute call with 400 {"error": "dataset not found", "dataset_id": "…"} before anything is written.
Every optional, authored step field below is preserved verbatim by all step-copying endpoints: GET /usecase/{id}/export, POST /import, POST /usecase/{id}/clone, POST /templates/{id}/steps, PATCH /templates/{id}/steps/{step_id}, POST /templates/{id}/apply, POST /usecase/{id}/import-template, POST /usecase/{id}/steps/{step_id}/update-from-template, the wizard step endpoints, and execution creation (EXECUTION_STEP# records). The list is a single constant on the server (step_copy.COPYABLE_STEP_FIELDS) that a test keeps identical to the CLI's ExportStep model, so a field cannot be dropped on one path only.
Copy rule: a field is copied when it is present and neither null nor ""; false and 0 (e.g. network_mock_passthrough: false, network_timeout: 0) are kept.
| Field | Type | Step types | Description |
|---|---|---|---|
secret_key |
string | secret | Key of the usecase secret typed into the focused field |
capture_variable |
string | retrieve_value, transform, download | Variable the step's result is stored in |
assertion_variable |
string | assertion | Variable compared by the assertion |
validation_type |
string | validation, assertion | string, number, date, … |
validation_operator |
string | validation, assertion | e.g. exact, contains, greater_than |
validation_value |
string | validation, assertion | Expected value |
value_type |
string | retrieve_value | text, number, date, … |
value_source |
string | retrieve_value | Where the value is read from |
value_format |
string | retrieve_value | Date format when value_type: "date" |
value_step |
string | (legacy) | Kept for very old exports; no runtime meaning |
enable_advanced_click_types |
boolean | navigation | Allow double/right-click actions |
browser_action |
string | browser | navigate, reload, back, forward, … |
browser_args |
string (JSON) | browser | Action arguments, e.g. {"url": "…"} |
transform_operation |
string | transform | math, date, string, … |
transform_args |
string (JSON) | transform | Operation arguments |
network_* (10 fields) |
see Network Assertion Fields | network_assertion | Request / mock / response / timing fields |
repeat_columns, repeat_rows |
see Repeat Step Fields | repeat | Inline dataset (not allowed on template steps; not copied onto execution steps) |
dataset_id |
string | repeat | Application dataset reference, alternative to the inline dataset (not allowed on template steps; not copied onto execution steps — the resolved rows are) |
Runtime state (cached_steps, cache_last_updated, trajectory_s3_key, trajectory_last_updated) is not a definition field: it is copied only from STEP# to EXECUTION_STEP# and never by export, clone or template operations.
Create a usecase from an export JSON (the shape produced by GET /usecase/{id}/export; this is what qa-studio tests import posts).
Endpoint: POST /import
Required Scopes: api/usecases.write — plus api/templates.read when the body contains template placeholder steps.
Request Body: the export JSON (exportVersion: "1.0", usecase, steps, variables, secrets, hooks, optional regionOverride). Every entry of steps is either a regular step or a template placeholder:
| Field | Type | Required | Description |
|---|---|---|---|
sort |
integer > 0 | yes | Slot where the template's steps are inserted |
step_type |
"template" |
yes | Marks the placeholder; not an executable step type |
template_id |
string (UUID) | yes | Template to expand. UUIDs are environment-specific. |
A placeholder may carry no other field (400). Placeholders are allowed only at the top level of steps (not under a repeat); the same template may be referenced several times. Templates cannot contain placeholders.
Processing order — nothing is written until all checks pass:
- Placeholder shape check (
400 {"success": false, "message": "steps[i]: …"}). - Scope check for
api/templates.readif any placeholder is present (403). - Every referenced template is loaded; any missing →
400 {"success": false, "error": "Template not found", "templateIds": ["…"]}. 3a. Everydataset_idon a repeat step is looked up in the target application (application_idof the body); any missing →400 {"success": false, "error": "dataset not found in this application", "message": "dataset not found in this application: <id>[, <id>]", "datasetIds": ["…"]}. Files withoutdataset_idread nothing.POST /usecase/{id}/cloneapplies the same check against the clone's application. - Steps are emitted in file order; each placeholder is replaced by the template's steps (new ids, all step definition fields, and the linkage
template_id/template_step_id/template_version); finalsortis1..n. - Template
variablesare merged into the file'svariables(the file's value wins on a duplicate key). Templatesecret_keys join the file'ssecrets(deduplicated) and getSECRET#placeholder records like file secrets.
Response (200):
{
"success": true,
"usecaseId": "7a7a…",
"message": "Usecase imported successfully",
"missingSecrets": ["shop_username", "shop_password"],
"templatesApplied": [{"templateId": "3f1c…", "version": 4, "stepsCreated": 3}]
}| Field | Type | Description |
|---|---|---|
usecaseId |
string | Id of the created usecase (a new usecase is always created; nothing is overwritten) |
missingSecrets |
string[] | Omitted when empty. Keys that still need a value via POST /usecase/{id}/secrets. |
templatesApplied |
object[] | Omitted when no placeholder was present. One entry per placeholder, in file order. |
Files without placeholders behave exactly as before (no template read, no templatesApplied).
Templates are reusable step blueprints stored at pk=TEMPLATES, sk=TEMPLATE#{id} (metadata, incl. version) with steps at pk=TEMPLATE#{id}, sk=STEP#{uuid}. Template CRUD (/templates, /templates/{id}, /templates/{id}/steps…) requires api/templates.write (or .read for GETs). The endpoints below connect templates to usecases.
Create a template — metadata, steps and variables — from a template file in one call (used by qa-studio templates create). A failed request leaves no half-built template behind.
Endpoint: POST /templates/import
Required Scopes: api/templates.write
Request Body (the template file format):
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
templateVersion |
"1.0" |
yes | — | File format version |
template.name |
string, min 1 | yes | — | |
template.description |
string | no | "" |
|
template.category |
string | no | "" |
|
template.tags |
string[] | no | [] |
|
steps |
step[], min 1 | yes | — | Same shape as export-JSON steps (sort, step_type, instruction + step definition fields). Renumbered 1..n in sort order. |
variables |
{key: string (min 1), value: string}[] |
no | [] |
Written as the template's VARIABLES record when non-empty |
Secrets are not declared — they are the secret_keys of the secret steps.
Errors: 400 {"error": "templates cannot contain template steps"} for a step_type: "template" step; 400 {"error": "repeat steps are not supported in templates"} for a repeat step or a parent_step_id; 400 {"error": "Invalid template file", "message": "<field>: <problem>; …"} for schema violations; 403 without api/templates.write.
Response (201):
{"id": "3f1c…", "name": "Login – Shop", "stepsCreated": 4}Records written: TEMPLATES / TEMPLATE#{id} (version: 1, created_by = caller), TEMPLATE#{id} / STEP#{uuid} per step, and TEMPLATE#{id} / VARIABLES when variables are present. A steps[].id in the body is accepted and ignored (new step ids are always generated — see PUT /templates/{id} for id-aware updates).
Replace a template's metadata, steps and variables atomically from a template file (used by qa-studio templates update). The body is the same format as POST /templates/import; steps may carry the id of an existing template step.
Endpoint: PUT /templates/{id}
Required Scopes: api/templates.write
Step matching:
| Step in body | Existing template step | Action |
|---|---|---|
has id, matches |
yes | Whole-item put: id and created_at kept, sort = position in the body, every other field from the body (a field removed from the body disappears) |
has id, no match |
— | 400 {"error": "steps[i]: id <id> does not belong to template <tid>"} — nothing written |
no id |
— | Put with a fresh uuid |
| — | exists, absent from body | Deleted |
Everything — step puts/deletes, one metadata Update (name, description, category, tags, updated_at, version + 1), and a VARIABLES put (non-empty) or delete (empty) — goes into one transact_write_items, so there is exactly one version bump and a failure leaves the template unchanged. DynamoDB transactions hold 100 items; a body with more than 98 steps is 400 {"error": "template too large for an atomic update (max 98 steps)"}.
Errors: 400 for schema violations ({"error": "Invalid template file", "message": "steps: duplicate step id …"} incl. duplicate ids), step_type: "template" steps, repeat steps, unknown ids, oversize; 404 when the template does not exist; 403 without api/templates.write.
Response (200):
{"id": "3f1c…", "name": "Login – Shop", "version": 7, "stepsCreated": 1, "stepsUpdated": 4, "stepsDeleted": 0}stepsUpdated counts every step matched by id (whether or not its content changed). Use-case steps linked to a template step that was deleted keep their template_step_id; readers already tolerate that.
The use cases that have steps linked to a template (via POST /usecase/{id}/import-template or placeholder expansion in POST /import). Shown by qa-studio templates get / templates delete and by the web UI's delete dialog before a template is deleted.
Endpoint: GET /templates/{id}/usages
Required Scopes: api/templates.read
Response (200), sorted by name:
{"usages": [{"usecase_id": "7b2d…", "name": "Checkout"}, {"usecase_id": "9c01…", "name": "Refund"}], "count": 2}Backed by the TEMPLATE#{id} / USAGE#{usecase_id} records (see Template usage tracking); a record whose use case no longer exists is skipped and logged — this endpoint never writes. 404 when the template does not exist.
Endpoint: DELETE /templates/{id}
Required Scopes: api/templates.write
Order of operations: (1) for every USAGE# record, the linked use-case steps lose their template_id / template_step_id / template_version attributes (the steps themselves are kept — they become plain steps; the update is conditional on template_id still matching, so a step relinked concurrently is skipped); (2) the whole TEMPLATE#{id} partition (steps, variables, usages) is deleted; (3) the metadata record is deleted last, so a Lambda that dies halfway leaves a template that can simply be deleted again. The unlink runs under api/templates.write without per-use-case authorization — it is a system side effect of deleting a shared resource.
Response: 204 No Content (previously 200 {"message"}). 404 when the template does not exist.
Create a new usecase from a template.
Endpoint: POST /templates/{id}/apply
Required Scopes: api/templates.read, api/usecases.write
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | yes | Name of the new usecase |
description |
string | no | Description |
starting_url |
string | no | Starting URL |
Response (200):
{
"success": true,
"usecaseId": "7f3c…",
"message": "Use case created from template successfully",
"missingSecrets": ["username", "password"]
}| Field | Type | Description |
|---|---|---|
usecaseId |
string | Id of the created usecase |
missingSecrets |
string[] | Omitted when empty. Distinct secret_key values of the copied secret steps. Nothing is stored for them yet — the caller collects the values and stores them via POST /usecase/{id}/secrets (the web UI shows one password field per key on the template form; until a value is set, a run fails at that step). |
All step definition fields of the template steps are copied; template variables become the usecase's variables. The created usecase has no application_id and its steps carry no template linkage.
Insert a template's steps into an existing usecase.
Endpoint: POST /usecase/{id}/import-template
Required Scopes: api/usecases.write, api/templates.read
Resource authorization: edit access to the usecase's application (creator-retains-edit applies). 404 when the usecase does not exist.
Request Body:
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
template_id |
string | yes | — | Template to insert |
insert_position |
integer | no | -1 |
Ordinal position: the template's first step becomes step N. See the contract below. |
insert_position |
Meaning |
|---|---|
omitted / -1 |
append after the last step |
0 |
treated as 1 (back-compat with the web UI's "At the beginning") |
1 … n+1 |
template's first step becomes step N (n = current step count) |
> n+1, < -1, non-integer |
400 {"error": "insert_position must be …"}; nothing is written |
After the insert all steps of the usecase are renumbered 1..n contiguously (gaps in historical sort values disappear); only steps whose sort changed are rewritten.
Response (201):
{
"message": "Template imported successfully",
"steps_created": 4,
"missingSecrets": ["username", "password"]
}missingSecrets has the same meaning as for Apply Template and is omitted when empty. The web UI only asks for keys the use case does not have yet (GET /usecase/{id}/secrets). Inserted steps carry template_id, template_step_id and template_version (the template's version at insert time); template variables are merged into the usecase variables without overwriting existing keys.
Replace a linked usecase step with the current content of its template step.
Endpoint: POST /usecase/{id}/steps/{stepId}/update-from-template
Required Scopes: api/usecases.write, api/templates.read
Resource authorization: edit access to the usecase's application. 404 when the usecase, step, template or template step does not exist; 400 when the step has no template_id.
Response (200):
{ "message": "Step updated from template", "previous_version": 3, "new_version": 5 }pk, sk, id, sort and created_at are kept; instruction, step_type and every step definition field are taken from the template step (fields the template step no longer has are removed); template_version becomes the template's current version.
List the usecase's template-linked steps and whether their template has changed since they were inserted.
Endpoint: GET /usecase/{id}/template-updates
Required Scopes: api/usecases.read, api/templates.read
Resource authorization: view access to the usecase's application. 404 when the usecase does not exist.
Response (200):
{
"updates": [
{
"step_id": "…", "template_id": "…", "template_step_id": "…",
"current_version": 3, "latest_version": 5, "has_update": true
}
]
}| Field | Type | Description |
|---|---|---|
step_id |
string | Usecase step id |
template_id / template_step_id |
string | Linkage written at insert time |
current_version |
number | template_version stored on the step |
latest_version |
number | Template's current version |
has_update |
boolean | latest_version > current_version |
Steps without template_id, and steps whose template no longer exists, are not listed.
version is template-level (one number per template, starting at 1) and is incremented by every write to the template:
| Operation | Endpoint | Effect on version |
|---|---|---|
| Edit metadata | PATCH /templates/{id} |
+1 |
| Replace definition | PUT /templates/{id} |
+1 per request (atomic with the step puts/deletes) |
| Create step | POST /templates/{id}/steps |
+1 |
| Update step | PATCH /templates/{id}/steps/{step_id} |
+1 |
| Delete step | DELETE /templates/{id}/steps/{step_id} |
+1 |
| Reorder steps | PATCH /templates/{id}/steps/reorder |
+1 per request (atomic with the reorder) |
Consequently any step change flags all linked steps of that template as having an update; per-step versioning is not implemented. The bump is conditional on the metadata record existing (no upsert).
Variables, secrets and repeat datasets can be defined once on an application and are inherited by every use case stamped with its application_id. Precedence (lowest → highest): Application → Use case → CLI run override (--var, --dataset-file). Variables and secrets merge per key (a use-case key shadows the application key); datasets do not merge — a repeat step references an application dataset or carries inline rows.
All routes below are dispatched by the application_router Lambda (/applications/{proxy+}); none is an explicit API Gateway resource (the api stack is at 489/500 resources — see Architecture → Resource ceiling). Authorization uses the application in the path: reads need api/applications.read plus view on the application; writes need api/applications.write plus edit (Editor+ of the owning team or a Full share — the same rule as creating a use case in the application, and looser than PATCH /applications/{id}, which is Team Lead only). Status codes shared by all routes: 400 invalid application or dataset id / malformed JSON, 403 missing scope or role, 404 unknown application (or dataset).
| Method | Endpoint | Scope / access | Body | Response |
|---|---|---|---|---|
| GET | /applications/{id}/variables |
api/applications.read, view |
— | 200 {"variables": [{"key", "value"}]} ([] when none) |
| POST | /applications/{id}/variables |
api/applications.write, edit |
{"variables": [{"key", "value"}]} |
200 {"variables": [...]} |
POST replaces the whole list (same as POST /usecase/{id}/variables): send every variable to keep. Rules: key matches ^[A-Za-z_][A-Za-z0-9_]*$, keys are unique (400 duplicate key <k>), at most 100 entries, value is a string. Stored at APPLICATION#{id} / VARIABLES with updated_at and updated_by.
curl -X POST 'https://api.example.com/applications/app-123/variables' \
-H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' \
-d '{"variables": [{"key": "base_api", "value": "https://api.staging.example.com"}]}'| Method | Endpoint | Scope / access | Body | Response |
|---|---|---|---|---|
| GET | /applications/{id}/secrets |
api/applications.read, view |
— | 200 {"secrets": [{"key", "secret_name", "description", "created_at"}]} — keys only, never values |
| POST | /applications/{id}/secrets |
api/applications.write, edit |
{"secrets": [{"key", "value"}]} |
201 {"created": ["<key>"], "updated": ["<key>"]} |
| DELETE | /applications/{id}/secrets |
api/applications.write, edit |
{"secret_key": "<key>"} |
204 No Content; 404 {"error": "Secret '<key>' not found"} |
Secrets live in AWS Secrets Manager as {SECRET_PREFIX}/application/{app_id}/{key}, tagged application_id (listing filters by tag and name prefix). key matches ^[A-Za-z0-9_.-]{1,128}$ (400 Invalid secret key); value must not be empty (400 value must not be empty — Secrets Manager cannot store an empty string); the list must be non-empty and keys unique. Every entry is validated before the first write. POST creates missing keys and updates existing ones; DELETE removes the secret without a recovery window. There is no application-level value endpoint — values are only read through GET /usecase/{id}/secrets/{key}/value by executors and the worker (see below). DELETE /applications/{id} removes all application secrets by tag.
curl -X POST 'https://api.example.com/applications/app-123/secrets' \
-H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' \
-d '{"secrets": [{"key": "admin_password", "value": "'"$ADMIN_PASSWORD"'"}]}'
# → 201 {"created": ["admin_password"], "updated": []}A dataset is a named columns + rows table (APPLICATION#{id} / DATASET#{dataset_id}) that repeat steps of the application's use cases reference by dataset_id instead of carrying inline rows.
| Method | Endpoint | Scope / access | Body | Response |
|---|---|---|---|---|
| GET | /applications/{id}/datasets |
api/applications.read, view |
— | 200 {"datasets": [{"dataset_id", "name", "description", "column_count", "row_count", "updated_at"}]} sorted by name (summaries, no rows) |
| POST | /applications/{id}/datasets |
api/applications.write, edit |
{"name", "description"?, "columns", "rows"} |
201 {"dataset": {...}} |
| GET | /applications/{id}/datasets/{datasetId} |
api/applications.read, view |
— | 200 {"dataset": {"dataset_id", "name", "description", "columns", "rows", "created_at", "updated_at", "created_by"}} |
| PUT | /applications/{id}/datasets/{datasetId} |
api/applications.write, edit |
{"name", "description"?, "columns", "rows"} (full replace) |
200 {"dataset": {...}} |
| DELETE | /applications/{id}/datasets/{datasetId} |
api/applications.write, edit |
— | 204 No Content, or 409 {"error": "dataset is referenced by repeat steps of this application; remove the references first", "usecases": [{"usecase_id", "name"}]} |
Validation (400 {"error": "<joined>", "errors": ["<rule>", ...]}, one entry per failed rule, same strings as inline repeat steps):
| Field | Type | Required | Rule |
|---|---|---|---|
name |
string | yes | ^[A-Za-z_][A-Za-z0-9_ -]{0,63}$ (letters, digits, spaces, _, -; starts with a letter or _; ≤ 64 chars). Unique within the application, case-insensitive: 409 {"error": "a dataset named \"<name>\" already exists in this application"} on create and on rename |
description |
string | no ("") |
— |
columns |
list[string] | yes | 1–20 identifiers ^[A-Za-z_][A-Za-z0-9_]*$, unique, none of UniqueID, Time, ExecutionID, CreatedAt, RepeatIndex, RepeatTotal |
rows |
list[list[string]] | yes | 1–100 rows, each with exactly len(columns) string cells; json.dumps(columns) + json.dumps(rows) ≤ 200 KB |
PUT keeps dataset_id, created_at and created_by. The delete check walks the application's use cases and their steps (bounded queries, no scan); the usecases list is sorted by name.
curl -X POST 'https://api.example.com/applications/app-123/datasets' \
-H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' \
-d '{"name": "customers", "columns": ["first_name", "last_name", "email"],
"rows": [["Ada", "Lovelace", "ada@example.com"], ["Alan", "Turing", "alan@example.com"]]}'
# → 201 {"dataset": {"dataset_id": "0192…", "name": "customers", "columns": [...], "rows": [...], ...}}The use-case routes return the effective configuration, so the CLI, TUI and worker keep reading key/value and see inherited values without any change:
| Endpoint | Change |
|---|---|
GET /usecase/{id}/variables |
Returns the application layer merged with the use case's own rows. Each row carries "source": "application" | "usecase". Application rows that the use case does not shadow come first, sorted by key; then the use-case rows in stored order. POST /usecase/{id}/variables is unchanged and must receive only the use case's own rows (the web UI never posts inherited rows back). |
GET /usecase/{id}/secrets |
Lists the use case's secrets followed by the owning application's secrets whose key is not shadowed; each row carries source. Values are never returned. |
GET /usecase/{id}/secrets/{key}/value |
Resolves the use-case secret first, then the application secret of the same key; 404 only when both are missing. The access log line gains source=usecase|application|none. Scope and access are unchanged (api/usecases.execute, execute). |
POST /usecase/{id}/execute, POST /test-suites/{id}/execute |
Snapshot the same effective variable list onto the execution (EXECUTION#{id} variables record) and resolve dataset_id repeat steps before expansion (see below). |
Example GET /usecase/{id}/variables:
{
"variables": [
{"key": "base_api", "value": "https://api.staging.example.com", "source": "application"},
{"key": "environment", "value": "staging", "source": "usecase"}
]
}A use case without an application resolves exactly as before (every row source: "usecase").
GET /usecase/{id}/executions/{executionId}/variables (scope api/executions.read, view access; the run's worker token is accepted too) returns three fields:
| Field | Type | Meaning |
|---|---|---|
variables |
list[{key: str, value: str}] |
The use case's current USECASE_VARIABLES rows (no application rows; edits made after the run show up here). Kept for compatibility. |
runtime_variables |
list[{key: str, value: str}] |
Variables captured during the run (retrieve_value / transform), final value per key. |
execution_variables |
dict[str, str] |
Snapshot of the merged variables the run started with (application + use case + overrides), read from EXECUTION#{id} / EXECUTION_VARIABLES. Filled for single runs (stored as a {key, value} list) and suite runs (stored as a map); {} when nothing was recorded. |
Clients should prefer execution_variables and fall back to variables only when it is empty (the CLI does).
| Endpoint | Behaviour |
|---|---|
POST /usecase/{id}/steps, PATCH /usecase/{id}/steps/{step_id} |
Accept dataset_id on repeat steps (XOR with repeat_columns + repeat_rows; see Repeat Step Fields). The dataset must exist in the use case's application: 400 {"error": "dataset not found in this application"}. |
POST /templates/{id}/steps, PATCH /templates/{id}/steps/{step_id} |
400 dataset references (dataset_id) are not supported in templates. |
GET /usecase/{id}/export |
Keeps dataset_id on the step (rows are not inlined), so the export is byte-stable and round-trips within the same application. |
POST /import, POST /usecase/{id}/clone |
Validate every dataset_id against the target application before writing anything: 400 {"success": false, "error": "dataset not found in this application", "message": "…: <id>", "datasetIds": ["<id>"]}. |
POST /applications/{id}/usecases (move in) |
409 {"error": "use case references application datasets", "usecases": [{"usecase_id", "name", "steps": [{"id", "instruction"}]}]} — switch those steps to inline rows (or re-create the dataset in the target application and repoint them) first. Removing use cases from an application is not affected. |
POST /usecase/{id}/execute, POST /test-suites/{id}/execute |
Copy the dataset's columns/rows onto the referencing steps before expand_repeat_steps, so repeat_datasets / --dataset-file overrides still replace the table by label. Missing dataset → 400 {"error": "dataset not found", "dataset_id": "…"} (suite: plus details.usecase_id) before any record is written. |
Read-only views of the cost allocation the daily collector writes (design: .kiro/docs/cost-allocation/design.md). Nothing is computed on request: each route returns the dimension's USAGE#… / SUMMARY item from DynamoDB and, where the response has days, the last 30 rows of the dimension's S3 ledger (cost_allocation/…ndjson). All three routes are served by the application_router Lambda; GET /applications/{id}/costs rides /applications/{proxy+}, the other two are explicit API Gateway resources (the api stack is at 489/500 resources after them).
Until the collector has run for a dimension, summary is null and days is [] — still 200, never 404. No new scope: reads reuse api/applications.read / api/usecases.read plus view on the resource in the path (team members, Viewer+, or a share at any level); /dashboard/costs filters rows exactly like /dashboard/overview.
| Method | Endpoint | Scope / access | Response |
|---|---|---|---|
| GET | /applications/{id}/costs |
api/applications.read, view on the application |
200 {"summary": <CostSummary>|null, "days": [<LedgerRow>…], "usecases": [{"usecase_id", "name", "summary": <CostSummary>|null}]} |
| GET | /usecase/{id}/costs |
api/usecases.read, view on the use case |
200 {"summary": <CostSummary>|null, "days": [<LedgerRow>…]} |
| GET | /dashboard/costs |
api/applications.read; rows filtered to visible applications |
200 {"overall": <CostSummary>|null, "applications": [{"application_id", "summary": <CostSummary>|null}]} |
Shared status codes: 400 invalid id, 403 missing scope or no view access, 404 unknown application / use case (or a use case hidden by a per-test-case override), 500 when the summary item or ledger could not be read.
GET /applications/{id}/costs — the application's summary, its 30-day series and one row per associated use case (APPLICATION#{id} / USECASE#…), sorted by window_30d descending; use cases without a summary come last with summary: null. Use case summaries and names are fetched with one BatchGetItem (chunked at 100 keys).
curl 'https://api.example.com/applications/app-123/costs' -H 'Authorization: Bearer <token>'{
"summary": {
"as_of": "2026-11-04",
"window_1d": 1.42, "window_1d_trend": "up", "window_1d_change_pct": 29.1, "window_1d_list": 1.5,
"window_7d": 9.8, "window_7d_trend": "up", "window_7d_change_pct": 10.1, "window_7d_list": 10.2,
"window_30d": 42.1, "window_30d_trend": null, "window_30d_change_pct": null, "window_30d_list": 44.0,
"by_source_30d": {"nova_act": 39.0, "compute": 3.1},
"days_available": 31,
"estimated_days": 1,
"updated_at": "2026-11-05T06:02:11Z"
},
"days": [
{"date": "2026-10-06", "estimated": false, "application_id": null,
"cost": [{"service": "nova_act", "cost": 0.9, "list_cost": 0.95, "usage_seconds": 720.0, "launches": null}],
"buckets": {"unassigned": 0.0, "unallocated": 0.07, "unmetered": 0.0},
"collected_at": "2026-10-07T06:02:11Z"},
"…"
],
"usecases": [
{"usecase_id": "0192…", "name": "Checkout", "summary": {"as_of": "2026-11-04", "window_30d": 30.0, "…": "…"}},
{"usecase_id": "0193…", "name": "Brand new", "summary": null}
]
}GET /usecase/{id}/costs — the use case's summary and 30-day series. days comes from the use case's current ledger file, i.e. the one under the application it belongs to now (cost_allocation/_unassigned/… for a use case without an application, which only studio admins can view). Rows written while the use case belonged to a previous application stay in that application's folder as history and are not returned, so the series restarts when a use case is moved.
curl 'https://api.example.com/usecase/0192…/costs' -H 'Authorization: Bearer <token>'
# → 200 {"summary": {...}, "days": [{"date": "2026-10-06", "application_id": "app-123", "cost": [...], "buckets": null, ...}, "…"]}GET /dashboard/costs — one {application_id, summary} row per application the caller may view (own teams' applications plus shared ones; studio admins see all), sorted by window_30d descending with missing summaries last. overall is the studio-wide USAGE#OVERALL / SUMMARY and is returned only to studio admins (isAdmin or the api/admin scope); every other caller gets "overall": null. The unassigned / unallocated / unmetered buckets are not broken out as rows (they are visible per day in the overall ledger's buckets). This route reads SUMMARY items only — one BatchGetItem per 100 applications — and never a ledger.
curl 'https://api.example.com/dashboard/costs' -H 'Authorization: Bearer <token>'
# → 200 {"overall": {"as_of": "2026-11-04", "window_30d": 99.0, "…": "…"},
# "applications": [{"application_id": "app-123", "summary": {...}}, {"application_id": "app-456", "summary": null}]}CostSummary (the summary / overall object; the SUMMARY item minus pk/sk). Money is USD, serialised with 6 decimals.
| Field | Type | Description |
|---|---|---|
as_of |
YYYY-MM-DD |
Newest day included |
window_1d, window_7d, window_30d |
number | Allocated bill over the last 1 / 7 / 30 days ending at as_of |
window_1d_trend, window_7d_trend, window_30d_trend |
"up" | "down" | "flat" | null |
Current window vs. the previous window of the same length; null until 2 × window days of data exist; flat within ±5 % |
window_1d_change_pct, window_7d_change_pct, window_30d_change_pct |
number | null | Percentage behind the trend; null when the previous window is 0 |
window_1d_list, window_7d_list, window_30d_list |
number | The same windows at list price |
by_source_30d |
map source → number |
nova_act, compute |
days_available |
integer | Ledger rows that exist for the dimension (UI: "not enough data") |
estimated_days |
integer | Rows in the 30-day window still flagged Estimated by Cost Explorer |
updated_at |
ISO 8601 | When the collector last wrote the summary |
LedgerRow (one entry of days, oldest first, at most 30):
| Field | Type | Description |
|---|---|---|
date |
YYYY-MM-DD |
UTC day |
estimated |
boolean | Cost Explorer Estimated flag for that day |
application_id |
string | null | Use case rows only: the application the use case belonged to on that day |
cost[] |
list | One entry per source: {"service": "nova_act" | "compute", "cost", "list_cost", "usage_seconds", "launches": integer | null} (launches is set for compute only) |
buckets |
object | null | Application and overall rows only: {"unassigned", "unallocated", "unmetered"} in USD |
collected_at |
ISO 8601 | Last (re-)processing time of the row |
Create and start a usecase execution.
Endpoint: POST /usecase/{id}/execute
Path Parameters:
id(string, required) - Usecase ID
Query Parameters:
-
trigger-type(string, optional, defaultOnDemandHeadless) - Execution trigger type:Value Behaviour Used by OnDemandHeadless(default)Starts a cloud worker: one Lambda MicroVM per execution UI "Trigger Execution", suite runs, API callers without the parameter ScheduledStarts a cloud worker (one Lambda MicroVM per execution) EventBridge schedules ci_runnerCreates the execution record only, no cloud worker. The only trigger allowed for test_platform: "electron"use casesCI/CD runner ( qa-studio run)Any other value, including the removed use-case-level
OnDemand, returns400naming the valid values, before any record is written. (OnDemandis still valid onPOST /test-suites/{id}/execute, where it means "spawn each use case headless".) Historical execution records may still carrytrigger_type: "OnDemand". -
suite-execution-id(string, optional) - Suite execution ID if part of a suite -
suite-id(string, optional) - Suite ID if part of a suite
Required Scopes: api/usecases.execute or api/admin, plus execute on the use case's owning application (Executor+ of the owning team, a team with an Execute/Full share, or an OAuth client bound to such a team). Orphan use cases (no application) are Admin-only. The worker identity can't start executions.
For Scheduled and OnDemandHeadless runs the execution record gets a worker_token_hash and the worker gets the matching token as execution_token in the MicroVM run-hook payload (see X-QA-Execution-Token). The token is never returned in the response.
Request Body (optional JSON):
| Field | Type | Default | Description |
|---|---|---|---|
repeat_datasets |
object | absent | {"<repeat label>": {"columns": [...], "rows": [[...]]}} — replaces the stored rows of the labelled repeat step for this execution. Columns must equal the repeat's columns (order-insensitive; rows are re-ordered to the stored column order). Same caps as stored datasets. |
{
"repeat_datasets": {
"Login as each user": {
"columns": ["email", "role"],
"rows": [["alice@staging.example", "Administrator"], ["bob@staging.example", "Viewer"]]
}
}
}400 (no records written) when: malformed JSON, unknown label, column mismatch, caps exceeded, or a repeat step that cannot be unrolled (zero rows, zero children). POST /test-suites/{id}/execute rejects repeat_datasets with 400 "repeat_datasets is not supported for suites".
Response (Scheduled/OnDemandHeadless):
{
"status": "microvm started",
"usecaseId": "usecase-123",
"executionId": "execution-456",
"microvmId": "mvm-0123456789abcdef0",
"cloudWatchLogsUrl": "https://console.aws.amazon.com/cloudwatch/..."
}cloudWatchLogsUrl is a deep link to the worker's log stream (/qa-studio/<baseName>/microvm, stream named after the execution id). microvmId is the VM's identifier; both are stored on the execution record as microvm_id and cloudwatch_logs_url. If the launch fails (after the RunMicrovm throttling retries, 5 attempts) the response is 500 {"error": "Failed to start MicroVM: …"} and the execution is marked failed with that message.
Response (ci_runner):
{
"status": "execution created",
"usecaseId": "usecase-123",
"executionId": "execution-456"
}Error Responses:
400 Bad Request- Missing usecase ID or invalid trigger type400 Bad Request-{"error": "Electron usecases can only be executed by the CI runner (qa-studio run)"}whentest_platformiselectronandtrigger-typeis notci_runner. Returned before any execution record is written. The suite execute endpoint returns the same error withdetails.usecase_idslisting the offending usecases.400 Bad Request-{"error": "dataset not found", "dataset_id": "…"}when a repeat step references an application dataset that no longer exists in the use case's application (or the use case has no application). Returned before any execution record is written. The suite execute endpoint returns the same body withdetails.usecase_id. Arepeat_datasetsoverride for that label does not bypass the check.401 Unauthorized- Missing or invalid token403 Forbidden- Missingapi/usecases.execute, no execute access on the owning application, orphan use case, unbound OAuth client, or worker identity404 Not Found- Usecase not found500 Internal Server Error- Server error
Example (cURL):
curl -X POST \
'https://api.example.com/usecase/usecase-123/execute?trigger-type=ci_runner' \
-H 'Authorization: Bearer <token>' \
-H 'Content-Type: application/json'Example (Python):
import requests
response = requests.post(
'https://api.example.com/usecase/usecase-123/execute',
params={'trigger-type': 'ci_runner'},
headers={
'Authorization': f'Bearer {access_token}',
'Content-Type': 'application/json'
}
)
data = response.json()
execution_id = data['executionId']
print(f"Execution created: {execution_id}")Example (JavaScript):
const response = await fetch(
'https://api.example.com/usecase/usecase-123/execute?trigger-type=ci_runner',
{
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
}
}
);
const data = await response.json();
console.log(`Execution created: ${data.executionId}`);Set the status of an execution. Called by the runner (worker token or api/executions.write) when the run starts and when it finishes; CI runners use it the same way.
Endpoint: PATCH /usecase/{id}/executions/{executionId}/status
Required Scopes: api/executions.write or api/admin, or a valid X-QA-Execution-Token
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
status |
string | yes | pending, running, completed, failed, success |
error_message |
string | no | Stored on the record for failed; the runner uses the prefixes timeout: and terminated: for runs stopped by a ceiling or SIGTERM, the reconciler uses reconciler: |
nova_session_id |
string | no | Nova Act session id for the artifact browser |
Response: 200 {"execution_id", "status", "updated_at"}. On a terminal transition (success, failed) the Lambda also records the VM's lifetime on the execution record (microvm_started_at / microvm_terminated_at from GetMicrovm, best-effort; see MicroVM attributes), emits nova-act-qa-studio.execution.status.changed, updates suite counters, clears cache artifacts (failed) and deletes the RUNNING_EXECUTIONS tracking record (MicroVM).
Error Responses:
400 Bad Request- Missing or invalidstatus404 Not Found- Execution not found409 Conflict-{"error": "execution already terminal", "status": "<current>"}: the record is alreadysuccess,failed,stopped,errororcompletedand the request carries a different status. Nothing is written. Repeating the same terminal status returns200(idempotent no-op). This is how astoppedexecution staysstoppedwhen the terminated runner reportsfaileda few seconds later.
Stop a running cloud execution and terminate its worker.
Endpoint: POST /usecase/{id}/executions/{executionId}/stop
Required Scopes: api/executions.write or api/admin, plus execute on the owning application
Behaviour: the record is set to stopped first, then TerminateMicrovm(microvm_id) is called; the platform's /terminate hook SIGTERMs the runner, whose own failed report is rejected with 409. The VM's lifetime is then recorded on the execution record (microvm_started_at from GetMicrovm.startedAt, microvm_terminated_at = the stop time, because the VM is still winding down; best-effort) and the RUNNING_EXECUTIONS tracking record is deleted. On dev the VM reaches TERMINATED about 22 s after the call.
Response:
{ "status": "stopped", "executionId": "execution-456", "microvmId": "mvm-0123456789abcdef0", "stoppedAt": "2026-05-01T10:35:00Z" }Error Responses:
400 Bad Request-execution has no associated MicroVM(aci_runnerexecution, or a record from before the MicroVM runner that only carriestask_arn— those are all terminal), orexecution is already in terminal state: <status>404 Not Found- Execution not found500 Internal Server Error- Server error (a failedTerminateMicrovmis logged, not returned: the record is alreadystopped)
Update the status of an individual execution step. Used by CI/CD runners to report step progress.
Endpoint: PATCH /usecase/{id}/executions/{executionId}/steps/{stepId}/status
Path Parameters:
id(string, required) - Usecase IDexecutionId(string, required) - Execution IDstepId(string, required) - Step ID
Required Scopes: api/executions.write or api/admin
Request Body:
{
"status": "running",
"started_at": "2024-01-15T10:30:00Z",
"completed_at": "2024-01-15T10:35:00Z",
"error_message": "Element not found"
}Request Body Fields:
status(string, required) - Step status- Valid values:
pending,running,completed,failed,skipped
- Valid values:
started_at(string, optional) - ISO8601 timestamp when step startedcompleted_at(string, optional) - ISO8601 timestamp when step completederror_message(string, optional) - Error message for failed steps
Response:
{
"success": true,
"step_id": "step-789",
"status": "running"
}Error Responses:
400 Bad Request- Invalid status value or missing required fields401 Unauthorized- Missing or invalid token403 Forbidden- Insufficient scopes404 Not Found- Execution or step not found500 Internal Server Error- Server error
Example (cURL):
curl -X PATCH \
'https://api.example.com/usecase/usecase-123/executions/execution-456/steps/step-789/status' \
-H 'Authorization: Bearer <token>' \
-H 'Content-Type: application/json' \
-d '{
"status": "completed",
"started_at": "2024-01-15T10:30:00Z",
"completed_at": "2024-01-15T10:35:00Z"
}'Example (Python):
import requests
from datetime import datetime
response = requests.patch(
f'https://api.example.com/usecase/{usecase_id}/executions/{execution_id}/steps/{step_id}/status',
headers={
'Authorization': f'Bearer {access_token}',
'Content-Type': 'application/json'
},
json={
'status': 'completed',
'started_at': datetime.utcnow().isoformat() + 'Z',
'completed_at': datetime.utcnow().isoformat() + 'Z'
}
)
print(f"Step status updated: {response.json()}")Example (JavaScript):
const response = await fetch(
`https://api.example.com/usecase/${usecaseId}/executions/${executionId}/steps/${stepId}/status`,
{
method: 'PATCH',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
status: 'completed',
started_at: new Date().toISOString(),
completed_at: new Date().toISOString()
})
}
);
const data = await response.json();
console.log('Step status updated:', data);Generate a presigned S3 URL for uploading execution-level artifacts (recording, logs). This allows CI/CD runners to upload large files directly to S3, bypassing API Gateway payload limits.
Endpoint: POST /usecase/{id}/executions/{executionId}/artifacts
Path Parameters:
id(string, required) - Usecase IDexecutionId(string, required) - Execution ID
Required Scopes: api/executions.write or api/admin
Request Body:
{
"type": "recording",
"filename": "recording.webm",
"content_type": "video/webm"
}Request Body Fields:
type(string, required) - Artifact type- Valid values:
recording,logs
- Valid values:
filename(string, required) - Original filenamecontent_type(string, required) - MIME type- For recording:
video/webm,video/mp4 - For logs:
text/plain
- For recording:
Response:
{
"artifact_id": "01933d7e-8f2a-7890-abcd-ef1234567890",
"upload_url": "https://s3.amazonaws.com/bucket/artifacts/usecase-123/executions/execution-456/recording.webm?X-Amz-Algorithm=...",
"expires_in": 3600,
"s3_key": "artifacts/usecase-123/executions/execution-456/recording.webm"
}Response Fields:
artifact_id- Unique artifact identifierupload_url- Presigned S3 URL for uploading (expires in 1 hour)expires_in- URL expiration time in secondss3_key- S3 object key where file will be stored
Error Responses:
400 Bad Request- Invalid artifact type, missing fields, or invalid content type401 Unauthorized- Missing or invalid token403 Forbidden- Insufficient scopes404 Not Found- Execution not found500 Internal Server Error- Server error
Example (cURL):
# Step 1: Get presigned URL
RESPONSE=$(curl -X POST \
'https://api.example.com/usecase/usecase-123/executions/execution-456/artifacts' \
-H 'Authorization: Bearer <token>' \
-H 'Content-Type: application/json' \
-d '{
"type": "recording",
"filename": "recording.webm",
"content_type": "video/webm"
}')
UPLOAD_URL=$(echo $RESPONSE | jq -r '.upload_url')
# Step 2: Upload file to S3 using presigned URL
curl -X PUT "$UPLOAD_URL" \
-H 'Content-Type: video/webm' \
--data-binary @recording.webmExample (Python):
import requests
# Step 1: Get presigned URL
response = requests.post(
f'https://api.example.com/usecase/{usecase_id}/executions/{execution_id}/artifacts',
headers={
'Authorization': f'Bearer {access_token}',
'Content-Type': 'application/json'
},
json={
'type': 'recording',
'filename': 'recording.webm',
'content_type': 'video/webm'
}
)
data = response.json()
upload_url = data['upload_url']
# Step 2: Upload file to S3
with open('recording.webm', 'rb') as f:
upload_response = requests.put(
upload_url,
headers={'Content-Type': 'video/webm'},
data=f
)
print(f"Upload status: {upload_response.status_code}")Example (JavaScript):
// Step 1: Get presigned URL
const response = await fetch(
`https://api.example.com/usecase/${usecaseId}/executions/${executionId}/artifacts`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
type: 'recording',
filename: 'recording.webm',
content_type: 'video/webm'
})
}
);
const data = await response.json();
// Step 2: Upload file to S3
const uploadResponse = await fetch(data.upload_url, {
method: 'PUT',
headers: {
'Content-Type': 'video/webm'
},
body: recordingBlob
});
console.log(`Upload status: ${uploadResponse.status}`);Generate a presigned S3 URL for uploading step-level artifacts (screenshots, traces). This allows CI/CD runners to upload step-specific files directly to S3.
Endpoint: POST /usecase/{id}/executions/{executionId}/steps/{stepId}/artifacts
Path Parameters:
id(string, required) - Usecase IDexecutionId(string, required) - Execution IDstepId(string, required) - Step ID
Required Scopes: api/executions.write or api/admin
Request Body:
{
"filename": "screenshot.png",
"content_type": "image/png"
}Request Body Fields:
filename(string, required) - Original filenamecontent_type(string, required) - MIME type- For screenshots:
image/png,image/jpeg - For traces:
application/json
- For screenshots:
Response:
{
"artifact_id": "01933d7e-8f2a-7890-abcd-ef1234567890",
"upload_url": "https://s3.amazonaws.com/bucket/artifacts/usecase-123/executions/execution-456/steps/step-789/screenshot.png?X-Amz-Algorithm=...",
"expires_in": 3600,
"s3_key": "artifacts/usecase-123/executions/execution-456/steps/step-789/screenshot.png"
}Error Responses:
400 Bad Request- Missing fields or invalid content type401 Unauthorized- Missing or invalid token403 Forbidden- Insufficient scopes404 Not Found- Execution or step not found500 Internal Server Error- Server error
Example (cURL):
# Step 1: Get presigned URL
RESPONSE=$(curl -X POST \
'https://api.example.com/usecase/usecase-123/executions/execution-456/steps/step-789/artifacts' \
-H 'Authorization: Bearer <token>' \
-H 'Content-Type: application/json' \
-d '{
"filename": "screenshot.png",
"content_type": "image/png"
}')
UPLOAD_URL=$(echo $RESPONSE | jq -r '.upload_url')
# Step 2: Upload file to S3 using presigned URL
curl -X PUT "$UPLOAD_URL" \
-H 'Content-Type: image/png' \
--data-binary @screenshot.pngExample (Python):
import requests
# Step 1: Get presigned URL
response = requests.post(
f'https://api.example.com/usecase/{usecase_id}/executions/{execution_id}/steps/{step_id}/artifacts',
headers={
'Authorization': f'Bearer {access_token}',
'Content-Type': 'application/json'
},
json={
'filename': 'screenshot.png',
'content_type': 'image/png'
}
)
data = response.json()
upload_url = data['upload_url']
# Step 2: Upload file to S3
with open('screenshot.png', 'rb') as f:
upload_response = requests.put(
upload_url,
headers={'Content-Type': 'image/png'},
data=f
)
print(f"Upload status: {upload_response.status_code}")Example (JavaScript):
// Step 1: Get presigned URL
const response = await fetch(
`https://api.example.com/usecase/${usecaseId}/executions/${executionId}/steps/${stepId}/artifacts`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
filename: 'screenshot.png',
content_type: 'image/png'
})
}
);
const data = await response.json();
// Step 2: Upload file to S3
const uploadResponse = await fetch(data.upload_url, {
method: 'PUT',
headers: {
'Content-Type': 'image/png'
},
body: screenshotBlob
});
console.log(`Upload status: ${uploadResponse.status}`);Request an asynchronous download of the Device Farm session recording for a mobile execution. The recording is downloaded from Device Farm and uploaded to S3 after a 5-minute delay (to allow Device Farm to finalize the session).
Endpoint: POST /usecase/{id}/executions/{executionId}/download-recording
Path Parameters:
id(string, required) - Usecase IDexecutionId(string, required) - Execution ID
Required Scopes: api/executions.write or api/admin
Request Body:
{
"session_arn": "arn:aws:devicefarm:us-west-2:123456789:session:project-id/session-id/00000",
"nova_session_id": "019d1234-5678-abcd-ef01-234567890abc"
}Request Body Fields:
session_arn(string, required) - Device Farm remote access session ARNnova_session_id(string, optional) - Nova Act session ID (defaults to execution ID)
Response (200 OK):
{
"message": "Recording download requested",
"delay_seconds": 300
}Example (cURL):
curl -X POST \
'https://api.example.com/api/usecase/usecase-123/executions/execution-456/download-recording' \
-H 'Authorization: Bearer <token>' \
-H 'Content-Type: application/json' \
-d '{
"session_arn": "arn:aws:devicefarm:us-west-2:123456789:session:project-id/session-id/00000"
}'Upsert a single captured runtime variable on an in-flight execution. Called by the CLI runner once per retrieve_value or transform step so the UI reflects captured values incrementally rather than only at completion.
Endpoint: POST /api/usecase/{id}/executions/{executionId}/runtime-variables
Scope: api/executions.write
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
id |
string | Usecase ID |
executionId |
string | Execution ID |
Request Body:
{
"key": "orderId",
"value": "ORD-42"
}| Field | Type | Required | Description |
|---|---|---|---|
key |
string | Yes | Variable name (≤128 chars, may be a DynamoDB reserved word) |
value |
string / number / bool | Yes | Variable value. Coerced to string on storage (≤4096 chars) |
Responses:
| Status | Meaning |
|---|---|
| 200 | {"status": "ok", "key": "<key>"} |
| 400 | Missing key/value, oversized value, or invalid body |
| 403 | Missing api/executions.write scope |
| 404 | Execution variables record not found |
| 500 | DynamoDB error |
The live-view URL is the user-visible streaming URL of a running AgentCore browser session. The runner publishes it at provisioning time; the frontend polls GET and the runner cleans up with DELETE on teardown.
Endpoint: POST /api/usecase/{id}/executions/{executionId}/live-view
Scope: api/executions.write
Request Body:
{
"live_view_url": "https://live.example.com/session-abc"
}| Field | Type | Required | Description |
|---|---|---|---|
live_view_url |
string | Yes | HTTPS URL of the live view, ≤2048 chars |
Responses: 200 ({"status": "ok"}), 400 (bad URL), 403, 500.
Endpoint: DELETE /api/usecase/{id}/executions/{executionId}/live-view
Scope: api/executions.write
Responses:
| Status | Meaning |
|---|---|
| 204 | Deleted |
| 403 | Missing scope |
| 404 | No live-view record to delete (callers may treat as already-clean) |
| 500 | DynamoDB error |
Partial update for Device Farm session metadata on a mobile execution. Called by the runner right after the session starts (session ARN only) and again after the session stops (final ARN + optional device details).
Endpoint: PATCH /api/usecase/{id}/executions/{executionId}/mobile-metadata
Scope: api/executions.write
Request Body (at least one field required; missing fields are NOT cleared server-side):
{
"device_farm_session_arn": "arn:aws:devicefarm:us-west-2:123456789012:session:abcdef",
"device_name": "Pixel 6",
"device_os_version": "Android 13"
}| Field | Type | Required | Description |
|---|---|---|---|
device_farm_session_arn |
string | No | Must start with arn:aws:devicefarm: |
device_name |
string | No | ≤512 chars |
device_os_version |
string | No | ≤512 chars |
Responses: 200 ({"status": "ok", "updated_fields": [...]}), 400, 403, 404 (execution not found), 500.
Custom HTTP headers sent with every request of the use case's executions. Stored as one HEADERS record per use case.
| Method | Endpoint | Scope | Body / Response |
|---|---|---|---|
| GET | /api/usecase/{id}/headers |
api/usecases.read |
{"headers": {"<name>": "<value>"}} ({} when none) |
| POST | /api/usecase/{id}/headers |
api/usecases.write |
Body {"headers": {"<name>": "<value>"}}; responds {"message": "Headers saved successfully"} |
POST replaces the whole map: send every header to keep. The web UI's Headers tab merges new rows into the current map on save and posts the map without the key on delete.
Read-only access to custom HTTP headers stored on an execution. The runner reads these and calls nova.page.set_extra_http_headers(...) before navigation. Returns {"headers": {}} (not 404) when no headers are configured.
Endpoint: GET /api/usecase/{id}/executions/{executionId}/headers
Scope: api/executions.read
Response:
{
"headers": {
"X-Custom": "value",
"Authorization": "Bearer …"
}
}Presigned S3 URLs for per-step trajectory JSONs used by Nova Act's trajectory-replay cache.
Endpoint: POST /api/usecase/{id}/steps/{stepId}/trajectory/upload-url
Scope: api/executions.write
Request Body:
{
"content_type": "application/json"
}Response:
{
"upload_url": "https://s3.example.com/…",
"s3_key": "uc-1/trajectories/step-1.json",
"expires_in": 900
}Calling this endpoint atomically writes trajectory_s3_key and trajectory_last_updated on the STEP record server-side. If the runner's subsequent PUT fails, the pointer is stale; the next replay attempt triggers cleanup via the clear_cache_fields extension on the step-status endpoint.
Endpoint: GET /api/usecase/{id}/steps/{stepId}/trajectory/download-url
Scope: api/executions.write (cache data is execution-flow-sensitive)
Response (200):
{
"download_url": "https://s3.example.com/…",
"expires_in": 900
}Response (404): when no trajectory has been recorded for the step — expected when the step hasn't been executed yet.
The existing PATCH /api/usecase/{id}/executions/{executionId}/steps/{stepId}/status endpoint accepts an optional clear_cache_fields array that lets the runner clean up stale cache pointers in the same request.
Allow-listed field names (anything else rejected 400):
trajectory_s3_keytrajectory_last_updatedcached_stepscache_last_updated
Example:
{
"status": "failed",
"error_message": "step timed out",
"clear_cache_fields": ["trajectory_s3_key", "trajectory_last_updated"]
}The server does a combined SET status = …, REMOVE trajectory_s3_key, trajectory_last_updated in a single DynamoDB update. No extra round-trip.
The interactive wizard runs wizard_worker.py on a Lambda MicroVM started with mode: wizard — the same image and supervisor as batch executions (see architecture → Stop and timeout paths). The session is bounded by maxWizardSessionMinutes (default 240) and ends after 30 minutes without activity. Only the two endpoints that touch the VM are listed here; the step/accept/reject/command/recording routes are UI-internal.
Endpoint: POST /wizard/start
Required Scopes: api/usecases.write or api/admin, plus edit on the application in the body
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
application_id |
string | Yes | Application the new use case belongs to |
name |
string | Yes | Name of the use case the wizard creates |
starting_url |
string | Yes | URL the wizard browser opens first |
description |
string | No | Use-case description |
executing_region |
string | No | Region the AgentCore browser runs in (default: the stack's default region) |
model_id |
string | No | Nova Act model id (default nova-act-v1.0) |
tags |
string[] | No | Tags for the new use case |
Response (201 Created):
{
"sessionId": "01a18ed0-…",
"usecaseId": "usecase-123",
"status": "initializing",
"microvmId": "mvm-0123456789abcdef0"
}The session id is also the execution id of the wizard's execution record (status: pending, wizard_status: active, microvm_id, cloudwatch_logs_url). A launch failure returns 500 {"error": "Failed to start MicroVM: …"} and leaves the session failed.
Endpoint: POST /wizard/{sessionId}/terminate/{usecaseId}
Required Scopes: api/usecases.write or api/admin, plus edit on the use case
Behaviour: the execution record is made terminal first (status = success, wizard_status = closed, completed_at; an already failed / timeout record keeps its status), then the terminate command is sent to the worker, then TerminateMicrovm(microvm_id) is called, the VM's lifetime is recorded on the execution record (microvm_started_at / microvm_terminated_at from GetMicrovm; the VM's own terminatedAt when the worker already ended the session, otherwise the terminate time; best-effort) and the RUNNING_EXECUTIONS record is deleted. The worker's SIGTERM handler only cleans up the browser.
Response (200 OK):
{ "status": "terminated", "microvmId": "mvm-0123456789abcdef0" }microvmId is omitted when the session's record carries no microvm_id (a session started before the MicroVM runner).
Error Responses:
400 Bad Request- Missing or invalidsessionId/usecaseId403 Forbidden- Insufficient scopes or no edit access to the use case404 Not Found- Session not found under the use case500 Internal Server Error- Error terminating the session (a failedTerminateMicrovmis logged, not returned)
List available AWS Device Farm devices for mobile testing. Returns devices with remote access enabled, filtered by platform.
Endpoint: GET /devices
Query Parameters:
platform(string, optional) - Filter by platform:"ANDROID"or"IOS"
Required Scopes: api/usecases.read or api/admin
Response (200 OK):
{
"devices": [
{
"arn": "arn:aws:devicefarm:us-west-2::device:ABC123",
"name": "Apple iPhone 15",
"platform": "IOS",
"os": "18.0",
"formFactor": "PHONE",
"manufacturer": "Apple",
"modelId": "iPhone15",
"availability": "HIGHLY_AVAILABLE"
}
]
}Response Fields:
arn- Device Farm device ARN (use this asdevice_arnwhen creating a usecase)name- Device display nameplatform-"ANDROID"or"IOS"os- Operating system versionformFactor-"PHONE"or"TABLET"manufacturer- Device manufactureravailability-"HIGHLY_AVAILABLE","AVAILABLE", or"BUSY"
Example (cURL):
curl -X GET \
'https://api.example.com/api/devices?platform=IOS' \
-H 'Authorization: Bearer <token>'Create a suite execution record and execution records for all usecases in a test suite with overrides applied. This endpoint is designed for CI/CD runners and does not start cloud workers.
Endpoint: POST /test-suites/{id}/execute
Path Parameters:
id(string, required) - Test suite UUID
Required Scopes: api/suite.write, api/executions.write
Request Body:
{
"trigger_type": "ci_runner",
"base_url": "https://example.com",
"variables": {
"username": "testuser",
"api_key": "secret123"
},
"region": "us-east-1",
"model_id": "us.amazon.nova-2-lite-v1:0"
}Request Body Schema:
trigger_type(string, required) - Must be "ci_runner"base_url(string, optional) - Base URL override for all usecases. Replaces domain/origin while preserving path and query parametersvariables(object, optional) - Variable overrides (key-value pairs). Precedence: CLI > usecase. Secret values are never copied into execution variables; the runner reads them per step.region(string, optional) - AWS region override for all executionsmodel_id(string, optional) - Bedrock model ID override for all executions
Response (200 OK):
{
"suite_execution_id": "01234567-89ab-cdef-0123-456789abcdef",
"suite_id": "01234567-89ab-cdef-0123-456789abcdef",
"status": "pending",
"created_at": "2024-02-16T12:00:00Z",
"execution_ids": [
{
"usecase_id": "01234567-89ab-cdef-0123-456789abcdef",
"execution_id": "01234567-89ab-cdef-0123-456789abcdef",
"usecase_name": "Login with valid credentials"
},
{
"usecase_id": "01234567-89ab-cdef-0123-456789abcdef",
"execution_id": "01234567-89ab-cdef-0123-456789abcdef",
"usecase_name": "Login with invalid password"
}
]
}Error Responses:
400 Bad Request - Invalid input or unresolved variables:
{
"error": "Unresolved variables",
"message": "Unresolved variables: username, password",
"details": {
"usecase_id": "01234567-89ab-cdef-0123-456789abcdef",
"usecase_name": "Login test"
}
}403 Forbidden - Insufficient permissions:
{
"error": "Forbidden",
"message": "Missing required scopes: api/suite.write, api/executions.write",
"required_scopes": ["api/suite.write", "api/executions.write"],
"token_scopes": ["api/suite.read"]
}404 Not Found - Test suite not found:
{
"error": "Test suite not found",
"message": "No test suite found with ID: 01234567-89ab-cdef-0123-456789abcdef"
}500 Internal Server Error - Database or system error:
{
"error": "Failed to execute test suite",
"message": "Error details"
}Example (cURL):
curl -X POST \
'https://api.example.com/test-suites/01234567-89ab-cdef-0123-456789abcdef/execute' \
-H 'Authorization: Bearer <token>' \
-H 'Content-Type: application/json' \
-d '{
"trigger_type": "ci_runner",
"base_url": "https://production.example.com",
"variables": {
"username": "prod_user",
"api_key": "prod_key_123"
},
"region": "us-west-2",
"model_id": "us.amazon.nova-2-lite-v1:0"
}'Example (Python):
import requests
response = requests.post(
'https://api.example.com/test-suites/01234567-89ab-cdef-0123-456789abcdef/execute',
headers={
'Authorization': f'Bearer {access_token}',
'Content-Type': 'application/json'
},
json={
'trigger_type': 'ci_runner',
'base_url': 'https://production.example.com',
'variables': {
'username': 'prod_user',
'api_key': 'prod_key_123'
},
'region': 'us-west-2',
'model_id': 'us.amazon.nova-2-lite-v1:0'
}
)
data = response.json()
suite_execution_id = data['suite_execution_id']
execution_ids = data['execution_ids']
print(f"Suite execution created: {suite_execution_id}")
print(f"Created {len(execution_ids)} executions")Example (JavaScript):
const response = await fetch(
'https://api.example.com/test-suites/01234567-89ab-cdef-0123-456789abcdef/execute',
{
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
trigger_type: 'ci_runner',
base_url: 'https://production.example.com',
variables: {
username: 'prod_user',
api_key: 'prod_key_123'
},
region: 'us-west-2',
model_id: 'us.amazon.nova-2-lite-v1:0'
})
}
);
const data = await response.json();
console.log(`Suite execution created: ${data.suite_execution_id}`);
console.log(`Created ${data.execution_ids.length} executions`);Notes:
- No cloud workers (MicroVMs) are started when using
trigger_type=ci_runner - All execution records are created with
status=pending - Base URL override replaces only the scheme and domain, preserving path and query parameters
- Variable merge precedence: CLI overrides > usecase variables (secret values are not merged)
- Maximum recommended suite size: 10 usecases (to avoid API Gateway timeout)
OAuth clients enable machine-to-machine (M2M) authentication for CI/CD pipelines and automated systems. Clients use the OAuth 2.0 client credentials flow and are scoped to specific API permissions.
Create a new OAuth client with specified scopes for CI/CD authentication.
Endpoint: POST /api/oauth-clients
Required Scopes: api/oauth-clients.write or api/admin, and Editor+ in teamId (or Admin)
Request Body:
{
"name": "CI/CD Runner - Production",
"teamId": "team-123",
"scopes": [
"api/suite.read",
"api/suite.write",
"api/executions.write"
]
}Request Body Fields:
name(string, required) - Client name for identificationteamId(string, required) - Team the client is bound to. The client acts withteam_role= your role in that team, capped ateditor(Admins createeditorclients)scopes(array, required) - List of OAuth scopes to grant- User can only grant scopes they possess (unless admin)
api/adminandapi/teams.managecan't be granted to a client, even by an Admin (400)
Response (201 Created):
{
"client_id": "7abc123def456",
"client_secret": "secret_xyz789...",
"client_name": "CI/CD Runner - Production",
"scopes": [
"api/suite.read",
"api/suite.write",
"api/executions.write"
],
"created_date": "2026-02-16T12:00:00Z",
"created_by": "user@example.com",
"team_id": "team-123",
"team_role": "editor",
"refresh_token_validity": 30,
"access_token_validity": 60,
"id_token_validity": 60
}Response Fields:
client_id- Unique client identifier for authenticationclient_secret- Client secret (ONLY shown once, cannot be retrieved later)client_name- Client namescopes- Granted OAuth scopescreated_date- ISO8601 timestamp of creationcreated_by- User who created the clientteam_id- Team the client is bound toteam_role- The client's role in that team (editor,executororviewer)- Token validity fields in minutes/days
Error Responses:
400 Bad Request- Invalid scopes,api/admin/api/teams.managerequested, missing name, missingteamId, or invalid request format401 Unauthorized- Missing or invalid token403 Forbidden- Insufficient scopes, not Editor+ inteamId, or privilege escalation attempt404 Not Found-teamIddoesn't exist429 Too Many Requests- Too many OAuth clients500 Internal Server Error- Server error
Example (cURL):
curl -X POST \
'https://api.example.com/api/oauth-clients' \
-H 'Authorization: Bearer <jwt_token>' \
-H 'Content-Type: application/json' \
-d '{
"name": "CI/CD Runner - Production",
"teamId": "team-123",
"scopes": [
"api/suite.read",
"api/suite.write",
"api/executions.write"
]
}'Example (Python):
import requests
response = requests.post(
'https://api.example.com/api/oauth-clients',
headers={
'Authorization': f'Bearer {jwt_token}',
'Content-Type': 'application/json'
},
json={
'name': 'CI/CD Runner - Production',
'teamId': 'team-123',
'scopes': [
'api/suite.read',
'api/suite.write',
'api/executions.write'
]
}
)
data = response.json()
client_id = data['client_id']
client_secret = data['client_secret']
print(f"Client ID: {client_id}")
# The secret is shown only once. Store it securely; never print or log it.
import boto3, json
boto3.client('secretsmanager').put_secret_value(
SecretId='qa-studio/ci-client', # an existing secret you control
SecretString=json.dumps({'client_id': client_id, 'client_secret': client_secret}),
)Example (JavaScript):
const response = await fetch('https://api.example.com/api/oauth-clients', {
method: 'POST',
headers: {
'Authorization': `Bearer ${jwtToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: 'CI/CD Runner - Production',
teamId: 'team-123',
scopes: [
'api/suite.read',
'api/suite.write',
'api/executions.write'
]
})
});
const data = await response.json();
console.log(`Client ID: ${data.client_id}`);
// data.client_secret is shown only once. Store it in a secrets manager
// (e.g. AWS Secrets Manager or your CI secret store); never print or log it.Security Notes:
- Client secret is ONLY returned at creation - save it securely
- Users cannot grant scopes they don't possess (prevents privilege escalation)
- Admin users can grant any scope except
api/adminandapi/teams.manage - The authorizer reads the binding on every (uncached) request; clients created before team binding have no
team_idand get 403 on team-owned resources. Re-create them with ateamId
List OAuth clients with metadata. Admins see every client; other callers see only clients bound to teams where they are Editor or Team Lead.
Endpoint: GET /api/oauth-clients
Required Scopes: api/oauth-clients.read or api/admin
Response (200 OK):
{
"clients": [
{
"client_id": "7abc123def456",
"client_name": "CI/CD Runner - Production",
"created_date": "2026-02-16T12:00:00Z",
"last_modified_date": "2026-02-16T12:00:00Z",
"created_by": "user@example.com",
"team_id": "team-123",
"allowed_oauth_scopes": [
"api/suite.read",
"api/suite.write",
"api/executions.write"
],
"refresh_token_validity": 30,
"access_token_validity": 60,
"id_token_validity": 60,
"enabled": true
}
],
"count": 1
}Error Responses:
401 Unauthorized- Missing or invalid token403 Forbidden- Insufficient scopes500 Internal Server Error- Server error
Example (cURL):
curl -X GET \
'https://api.example.com/api/oauth-clients' \
-H 'Authorization: Bearer <jwt_token>'Example (Python):
import requests
response = requests.get(
'https://api.example.com/api/oauth-clients',
headers={'Authorization': f'Bearer {jwt_token}'}
)
data = response.json()
print(f"Found {data['count']} OAuth clients")
for client in data['clients']:
print(f" - {client['client_name']} ({client['client_id']})")Example (JavaScript):
const response = await fetch('https://api.example.com/api/oauth-clients', {
headers: {
'Authorization': `Bearer ${jwtToken}`
}
});
const data = await response.json();
console.log(`Found ${data.count} OAuth clients`);
data.clients.forEach(client => {
console.log(` - ${client.client_name} (${client.client_id})`);
});Security Notes:
- Client secrets are NEVER returned in list responses
- Only shows clients created through the application (with metadata)
team_idisnullfor unbound legacy clients (shown as "Unbound (no access)" in the UI)
Delete an OAuth client, immediately revoking its credentials.
Endpoint: DELETE /api/oauth-clients/{clientId}
Path Parameters:
clientId(string, required) - OAuth client ID to delete
Required Scopes: api/oauth-clients.write or api/admin, and the caller must be the client's creator, a Team Lead of the client's team, or an Admin
Response (204 No Content): empty body
Error Responses:
400 Bad Request- Missing client ID401 Unauthorized- Missing or invalid token403 Forbidden- Client not created through application, or caller isn't the creator, a Team Lead of the client's team, or an Admin404 Not Found- Client not found500 Internal Server Error- Server error
Example (cURL):
curl -X DELETE \
'https://api.example.com/api/oauth-clients/7abc123def456' \
-H 'Authorization: Bearer <jwt_token>'Example (Python):
import requests
response = requests.delete(
'https://api.example.com/api/oauth-clients/7abc123def456',
headers={'Authorization': f'Bearer {jwt_token}'}
)
if response.status_code == 204:
print("OAuth client deleted successfully")Example (JavaScript):
const response = await fetch(
'https://api.example.com/api/oauth-clients/7abc123def456',
{
method: 'DELETE',
headers: {
'Authorization': `Bearer ${jwtToken}`
}
}
);
if (response.ok) {
console.log('OAuth client deleted successfully');
}Security Notes:
- Deletion is immediate - client cannot authenticate after deletion
- Only clients with metadata (created through application) can be deleted
- Only the creator, a Team Lead of the client's team, or an Admin can delete a client
Generate a new client secret by recreating the OAuth client. Old secret is immediately invalidated.
Endpoint: POST /api/oauth-clients/{clientId}/rotate-secret
Path Parameters:
clientId(string, required) - OAuth client ID to rotate
Required Scopes: api/oauth-clients.write or api/admin
Response (200 OK):
{
"client_id": "8def456ghi789",
"client_secret": "new_secret_abc123...",
"rotated_at": "2026-02-16T15:00:00Z"
}Response Fields:
client_id- New client ID (changes during rotation)client_secret- New client secret (ONLY shown once, cannot be retrieved later)rotated_at- ISO8601 timestamp of rotation
Error Responses:
400 Bad Request- Missing client ID, or the client holdsapi/admin/api/teams.manage(re-create it instead)401 Unauthorized- Missing or invalid token403 Forbidden- Caller isn't the creator, a Team Lead of the client's team, or an Admin404 Not Found- Client not found500 Internal Server Error- Server error
Example (cURL):
curl -X POST \
'https://api.example.com/api/oauth-clients/7abc123def456/rotate-secret' \
-H 'Authorization: Bearer <jwt_token>' \
-H 'Content-Type: application/json'Example (Python):
import requests
response = requests.post(
'https://api.example.com/api/oauth-clients/7abc123def456/rotate-secret',
headers={
'Authorization': f'Bearer {jwt_token}',
'Content-Type': 'application/json'
}
)
data = response.json()
new_client_id = data['client_id']
new_client_secret = data['client_secret']
print(f"New Client ID: {new_client_id}")
# The secret is shown only once. Store it securely; never print or log it.
import boto3, json
boto3.client('secretsmanager').put_secret_value(
SecretId='qa-studio/ci-client', # an existing secret you control
SecretString=json.dumps({'client_id': new_client_id, 'client_secret': new_client_secret}),
)
print(f"Rotated at: {data['rotated_at']}")Example (JavaScript):
const response = await fetch(
'https://api.example.com/api/oauth-clients/7abc123def456/rotate-secret',
{
method: 'POST',
headers: {
'Authorization': `Bearer ${jwtToken}`,
'Content-Type': 'application/json'
}
}
);
const data = await response.json();
console.log(`New Client ID: ${data.client_id}`);
// data.client_secret is shown only once. Store it in a secrets manager
// (e.g. AWS Secrets Manager or your CI secret store); never print or log it.
console.log(`Rotated at: ${data.rotated_at}`);Security Notes:
- Old secret is immediately invalidated - update all systems using the client
- Client ID changes during rotation - update configuration accordingly
- New secret is ONLY shown once - save it securely
- Only the creator, a Team Lead of the client's team, or an Admin can rotate a client
Implementation Notes:
- Cognito doesn't support in-place secret rotation
- Rotation deletes and recreates the app client with same settings
- All configuration (scopes, token validity,
team_id,team_role) is preserved
Endpoint: DELETE /users/{userId}
Path Parameters:
userId(string, required) - Cognito username of the user (theuserIdreturned byGET /users; a GUID on email-username pools)
Required Scopes: api/admin
Served by the application router (handlers/delete_user.py). In order:
- Last-admin guard: deleting the final member of the global
adminsgroup returns409; if the check itself fails,500and nothing is deleted. - Every team membership record (
TEAM#{team_id}/MEMBER#{user_sub}) of the user is deleted. - The Cognito user is deleted (an already-missing user is not an error).
- A
user_deleteaudit entry is written with the actor, the targetuser_subandremoved_teams.
Response: 204 No Content (empty body).
| Status | When |
|---|---|
| 204 | Deleted |
| 400 | Missing userId |
| 403 | Caller lacks api/admin |
| 409 | Last studio admin |
| 500 | Last-admin check or Cognito delete failed |
All error responses follow this consistent format:
{
"error": "Error type or category",
"message": "Detailed error description",
"required_scopes": ["api/executions.write"],
"token_scopes": ["api/executions.read"]
}Error Response Fields:
error- Short error type or categorymessage- Detailed human-readable error descriptionrequired_scopes(optional) - Scopes required for the operation (403 errors)token_scopes(optional) - Scopes present in the token (403 errors)details(optional) - Additional context-specific error details
| Status Code | Description | Common Causes |
|---|---|---|
200 OK |
Request succeeded | Successful GET, PATCH, POST operations |
201 Created |
Resource created successfully | Successful POST operations creating new resources |
204 No Content |
Successful deletion | Successful DELETE operations |
400 Bad Request |
Invalid input | Missing required fields, invalid values, malformed JSON |
401 Unauthorized |
Authentication failed | Missing token, expired token, invalid token |
403 Forbidden |
Insufficient permissions | Missing required scopes, privilege escalation attempt |
404 Not Found |
Resource not found | Invalid ID, deleted resource, wrong endpoint |
429 Too Many Requests |
Rate limit exceeded | Too many requests in short time period |
500 Internal Server Error |
Server error | Database errors, AWS service errors, unexpected exceptions |
Missing Authorization Header:
{
"error": "Unauthorized",
"message": "Missing Authorization header"
}Invalid or Expired Token:
{
"error": "Unauthorized",
"message": "Invalid or expired token"
}Solution: Obtain a new access token using the OAuth 2.0 client credentials flow.
Insufficient Scopes:
{
"error": "Forbidden",
"message": "Missing required scopes: api/suite.write, api/executions.write",
"required_scopes": ["api/suite.write", "api/executions.write"],
"token_scopes": ["api/suite.read"]
}Solution: Request an OAuth client with the required scopes or use a token with sufficient permissions.
Privilege Escalation Attempt:
{
"error": "Forbidden",
"message": "Cannot grant scopes that you don't possess: api/admin"
}Solution: Only grant scopes that your user account possesses, or contact an administrator.
Team Access Denied (scope present, but no role in the owning team or a matching share, which includes unbound OAuth clients):
{
"error": "Forbidden",
"message": "No access to this application"
}Other messages: "Insufficient permissions for this action" (role or share level too low), "Application not accessible" (orphan resource, Admin-only), "Team lead of the owning team required" (application update/delete).
Worker Without a Valid Execution Token:
{
"error": "Forbidden",
"message": "Worker capability required"
}Solution: Ask a Team Lead to add you (or bind your OAuth client) to the owning team with the required role. See Team ownership and access control.
Missing Required Fields:
{
"error": "Bad Request",
"message": "Missing required field: name"
}Invalid Field Values:
{
"error": "Bad Request",
"message": "Invalid status value: invalid_status. Valid values: pending, running, completed, failed, skipped"
}Unresolved Variables:
{
"error": "Unresolved variables",
"message": "Unresolved variables: username, password",
"details": {
"usecase_id": "01234567-89ab-cdef-0123-456789abcdef",
"usecase_name": "Login test"
}
}Solution: Provide all required variables via the variables parameter.
{
"error": "Test suite not found",
"message": "No test suite found with ID: 01234567-89ab-cdef-0123-456789abcdef"
}Solution: Verify the resource ID is correct and the resource exists.
{
"error": "Too Many Requests",
"message": "Rate limit exceeded. Please retry after 60 seconds."
}Solution: Implement exponential backoff and retry logic in your client.
API Gateway enforces the following rate limits to ensure fair usage and system stability:
Limits:
- Burst Capacity: 5,000 requests
- Steady-State Rate: 10,000 requests per second
Behavior:
- Requests exceeding the burst capacity are throttled
- Throttled requests receive a
429 Too Many Requestsresponse - Rate limits apply per AWS account
Best Practices:
- Implement exponential backoff for retry logic
- Cache responses when appropriate
- Batch operations when possible
- Monitor your request rate and adjust accordingly
Example Retry Logic (Python):
import requests
import time
def api_request_with_retry(url, headers, max_retries=3):
for attempt in range(max_retries):
response = requests.get(url, headers=headers)
if response.status_code == 429:
# Exponential backoff: 1s, 2s, 4s
wait_time = 2 ** attempt
print(f"Rate limited. Retrying in {wait_time}s...")
time.sleep(wait_time)
continue
return response
raise Exception("Max retries exceeded")Example Retry Logic (JavaScript):
async function apiRequestWithRetry(url, headers, maxRetries = 3) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
const response = await fetch(url, { headers });
if (response.status === 429) {
// Exponential backoff: 1s, 2s, 4s
const waitTime = Math.pow(2, attempt) * 1000;
console.log(`Rate limited. Retrying in ${waitTime}ms...`);
await new Promise(resolve => setTimeout(resolve, waitTime));
continue;
}
return response;
}
throw new Error('Max retries exceeded');
}Currently, the API does not implement pagination for list endpoints. All results are returned in a single response.
Future Enhancement: Pagination will be added in a future version using cursor-based pagination:
{
"items": [...],
"next_cursor": "eyJsYXN0X2lkIjogIjEyMyJ9",
"has_more": true
}Recommended Limits:
- OAuth clients list: Returns all clients (typically < 100)
- Test suite executions: Consider filtering by date range in future versions
This example demonstrates a complete CI/CD workflow using the API, from authentication to test execution and artifact upload.
#!/bin/bash
set -e
# Configuration
COGNITO_DOMAIN="your-user-pool-domain"
REGION="us-east-1"
API_BASE_URL="https://api.example.com"
CLIENT_ID="${OAUTH_CLIENT_ID}"
CLIENT_SECRET="${OAUTH_CLIENT_SECRET}"
SUITE_ID="01234567-89ab-cdef-0123-456789abcdef"
echo "=== CI/CD Test Execution Workflow ==="
# Step 1: Obtain access token
echo "Step 1: Obtaining access token..."
TOKEN_RESPONSE=$(curl -s -X POST \
"https://${COGNITO_DOMAIN}.auth.${REGION}.amazoncognito.com/oauth2/token" \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'grant_type=client_credentials' \
-d "client_id=${CLIENT_ID}" \
-d "client_secret=${CLIENT_SECRET}" \
-d 'scope=api/suite.read api/suite.write api/executions.read api/executions.write')
TOKEN=$(echo $TOKEN_RESPONSE | jq -r '.access_token')
if [ "$TOKEN" == "null" ] || [ -z "$TOKEN" ]; then
echo "ERROR: Failed to obtain access token"
echo $TOKEN_RESPONSE
exit 1
fi
echo "✓ Access token obtained"
# Step 2: Execute test suite
echo "Step 2: Executing test suite..."
SUITE_RESPONSE=$(curl -s -X POST \
"${API_BASE_URL}/test-suites/${SUITE_ID}/execute" \
-H "Authorization: Bearer ${TOKEN}" \
-H 'Content-Type: application/json' \
-d '{
"trigger_type": "ci_runner",
"base_url": "https://staging.example.com",
"variables": {
"username": "testuser",
"password": "testpass"
}
}')
SUITE_EXECUTION_ID=$(echo $SUITE_RESPONSE | jq -r '.suite_execution_id')
EXECUTION_IDS=$(echo $SUITE_RESPONSE | jq -r '.execution_ids')
echo "✓ Suite execution created: ${SUITE_EXECUTION_ID}"
echo "✓ Created $(echo $EXECUTION_IDS | jq length) executions"
# Step 3: Execute each usecase and report progress
echo "Step 3: Executing usecases..."
echo $EXECUTION_IDS | jq -c '.[]' | while read execution; do
USECASE_ID=$(echo $execution | jq -r '.usecase_id')
EXECUTION_ID=$(echo $execution | jq -r '.execution_id')
USECASE_NAME=$(echo $execution | jq -r '.usecase_name')
echo " Executing: ${USECASE_NAME}"
# Get execution steps
STEPS=$(curl -s -X GET \
"${API_BASE_URL}/usecase/${USECASE_ID}/executions/${EXECUTION_ID}/steps" \
-H "Authorization: Bearer ${TOKEN}")
# Execute each step
echo $STEPS | jq -c '.[]' | while read step; do
STEP_ID=$(echo $step | jq -r '.id')
STEP_NAME=$(echo $step | jq -r '.name')
echo " Step: ${STEP_NAME}"
# Mark step as running
curl -s -X PATCH \
"${API_BASE_URL}/usecase/${USECASE_ID}/executions/${EXECUTION_ID}/steps/${STEP_ID}/status" \
-H "Authorization: Bearer ${TOKEN}" \
-H 'Content-Type: application/json' \
-d "{
\"status\": \"running\",
\"started_at\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"
}" > /dev/null
# Execute step logic here
# ... your test execution code ...
# Upload screenshot if available
if [ -f "screenshot_${STEP_ID}.png" ]; then
ARTIFACT_RESPONSE=$(curl -s -X POST \
"${API_BASE_URL}/usecase/${USECASE_ID}/executions/${EXECUTION_ID}/steps/${STEP_ID}/artifacts" \
-H "Authorization: Bearer ${TOKEN}" \
-H 'Content-Type: application/json' \
-d '{
"filename": "screenshot.png",
"content_type": "image/png"
}')
UPLOAD_URL=$(echo $ARTIFACT_RESPONSE | jq -r '.upload_url')
curl -s -X PUT "$UPLOAD_URL" \
-H 'Content-Type: image/png' \
--data-binary @"screenshot_${STEP_ID}.png" > /dev/null
echo " ✓ Screenshot uploaded"
fi
# Mark step as completed
curl -s -X PATCH \
"${API_BASE_URL}/usecase/${USECASE_ID}/executions/${EXECUTION_ID}/steps/${STEP_ID}/status" \
-H "Authorization: Bearer ${TOKEN}" \
-H 'Content-Type: application/json' \
-d "{
\"status\": \"completed\",
\"completed_at\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"
}" > /dev/null
echo " ✓ Step completed"
done
# Upload execution-level artifacts
if [ -f "recording_${EXECUTION_ID}.webm" ]; then
echo " Uploading recording..."
ARTIFACT_RESPONSE=$(curl -s -X POST \
"${API_BASE_URL}/usecase/${USECASE_ID}/executions/${EXECUTION_ID}/artifacts" \
-H "Authorization: Bearer ${TOKEN}" \
-H 'Content-Type: application/json' \
-d '{
"type": "recording",
"filename": "recording.webm",
"content_type": "video/webm"
}')
UPLOAD_URL=$(echo $ARTIFACT_RESPONSE | jq -r '.upload_url')
curl -s -X PUT "$UPLOAD_URL" \
-H 'Content-Type: video/webm' \
--data-binary @"recording_${EXECUTION_ID}.webm" > /dev/null
echo " ✓ Recording uploaded"
fi
echo " ✓ Usecase completed: ${USECASE_NAME}"
done
echo "=== Workflow completed successfully ==="import requests
import time
from datetime import datetime
from typing import Dict, List
class NovaActCICDClient:
def __init__(self, cognito_domain: str, region: str, api_base_url: str,
client_id: str, client_secret: str):
self.cognito_domain = cognito_domain
self.region = region
self.api_base_url = api_base_url
self.client_id = client_id
self.client_secret = client_secret
self.access_token = None
self.token_expiry = 0
def get_access_token(self) -> str:
"""Obtain access token using OAuth 2.0 client credentials flow."""
# Check if token is still valid
if self.access_token and time.time() < self.token_expiry:
return self.access_token
token_url = f"https://{self.cognito_domain}.auth.{self.region}.amazoncognito.com/oauth2/token"
response = requests.post(
token_url,
headers={'Content-Type': 'application/x-www-form-urlencoded'},
data={
'grant_type': 'client_credentials',
'client_id': self.client_id,
'client_secret': self.client_secret,
'scope': 'api/suite.read api/suite.write api/executions.read api/executions.write'
}
)
response.raise_for_status()
data = response.json()
self.access_token = data['access_token']
self.token_expiry = time.time() + data['expires_in'] - 60 # Refresh 1 min early
return self.access_token
def execute_test_suite(self, suite_id: str, base_url: str = None,
variables: Dict[str, str] = None) -> Dict:
"""Execute a test suite with optional overrides."""
token = self.get_access_token()
payload = {'trigger_type': 'ci_runner'}
if base_url:
payload['base_url'] = base_url
if variables:
payload['variables'] = variables
response = requests.post(
f"{self.api_base_url}/test-suites/{suite_id}/execute",
headers={
'Authorization': f'Bearer {token}',
'Content-Type': 'application/json'
},
json=payload
)
response.raise_for_status()
return response.json()
def update_step_status(self, usecase_id: str, execution_id: str,
step_id: str, status: str, error_message: str = None):
"""Update the status of an execution step."""
token = self.get_access_token()
payload = {
'status': status,
'started_at': datetime.utcnow().isoformat() + 'Z'
}
if status in ['completed', 'failed']:
payload['completed_at'] = datetime.utcnow().isoformat() + 'Z'
if error_message:
payload['error_message'] = error_message
response = requests.patch(
f"{self.api_base_url}/usecase/{usecase_id}/executions/{execution_id}/steps/{step_id}/status",
headers={
'Authorization': f'Bearer {token}',
'Content-Type': 'application/json'
},
json=payload
)
response.raise_for_status()
return response.json()
def upload_step_artifact(self, usecase_id: str, execution_id: str,
step_id: str, file_path: str, content_type: str):
"""Upload a step artifact (screenshot, trace)."""
token = self.get_access_token()
# Get presigned URL
response = requests.post(
f"{self.api_base_url}/usecase/{usecase_id}/executions/{execution_id}/steps/{step_id}/artifacts",
headers={
'Authorization': f'Bearer {token}',
'Content-Type': 'application/json'
},
json={
'filename': file_path.split('/')[-1],
'content_type': content_type
}
)
response.raise_for_status()
data = response.json()
upload_url = data['upload_url']
# Upload file to S3
with open(file_path, 'rb') as f:
upload_response = requests.put(
upload_url,
headers={'Content-Type': content_type},
data=f
)
upload_response.raise_for_status()
return data['artifact_id']
def upload_execution_artifact(self, usecase_id: str, execution_id: str,
artifact_type: str, file_path: str,
content_type: str):
"""Upload an execution artifact (recording, logs)."""
token = self.get_access_token()
# Get presigned URL
response = requests.post(
f"{self.api_base_url}/usecase/{usecase_id}/executions/{execution_id}/artifacts",
headers={
'Authorization': f'Bearer {token}',
'Content-Type': 'application/json'
},
json={
'type': artifact_type,
'filename': file_path.split('/')[-1],
'content_type': content_type
}
)
response.raise_for_status()
data = response.json()
upload_url = data['upload_url']
# Upload file to S3
with open(file_path, 'rb') as f:
upload_response = requests.put(
upload_url,
headers={'Content-Type': content_type},
data=f
)
upload_response.raise_for_status()
return data['artifact_id']
# Usage example
if __name__ == '__main__':
client = NovaActCICDClient(
cognito_domain='your-user-pool-domain',
region='us-east-1',
api_base_url='https://api.example.com',
client_id='7abc123def456',
client_secret='secret_xyz789...'
)
# Execute test suite
result = client.execute_test_suite(
suite_id='01234567-89ab-cdef-0123-456789abcdef',
base_url='https://staging.example.com',
variables={'username': 'testuser', 'password': 'testpass'}
)
print(f"Suite execution created: {result['suite_execution_id']}")
print(f"Created {len(result['execution_ids'])} executions")
# Process each execution
for execution in result['execution_ids']:
usecase_id = execution['usecase_id']
execution_id = execution['execution_id']
print(f"Executing: {execution['usecase_name']}")
# Execute steps and update status
# ... your test execution logic ...
# Upload artifacts
client.upload_execution_artifact(
usecase_id, execution_id,
'recording', 'recording.webm', 'video/webm'
)Current Version: 1.2.0
The API uses semantic versioning (MAJOR.MINOR.PATCH):
- MAJOR: Breaking changes that require client updates
- MINOR: New features, backward compatible
- PATCH: Bug fixes, backward compatible
Version History:
- Added OAuth client management endpoints
- Added client credentials flow support for M2M authentication
- Added scope validation and privilege escalation prevention
- Added client secret rotation capability
- Added test suite execution endpoint for CI/CD
- Added support for base URL, variable, region, and model overrides
- Added structured logging and CloudWatch metrics
- Initial release with execution endpoints
- Added
ci_runnertrigger type - Added step status update endpoint
- Added artifact presigned URL endpoints
Deprecation Policy:
- Deprecated endpoints will be supported for at least 6 months
- Deprecation notices will be included in API responses
- Breaking changes will increment the major version
- Least Privilege: Grant only the minimum scopes required
- Secret Storage: Store client secrets in secure secret managers (AWS Secrets Manager, HashiCorp Vault)
- Secret Rotation: Rotate client secrets regularly (recommended: every 90 days)
- Access Review: Regularly review and remove unused OAuth clients
- Monitoring: Monitor OAuth client usage and authentication failures
- Token Expiry: Access tokens expire after 1 hour - implement automatic refresh
- Secure Storage: Never commit tokens or credentials to version control
- Environment Variables: Use environment variables or secret managers for credentials
- Token Validation: Validate token expiry before making API requests
- HTTPS Only: Always use HTTPS for API requests
- Input Validation: Validate and sanitize all input data
- Error Handling: Don't expose sensitive information in error messages
- Rate Limiting: Implement client-side rate limiting to avoid throttling
- Logging: Log API requests for audit purposes (exclude sensitive data)
- IP Whitelisting: Consider restricting API access to known IP ranges
- Egress: the worker VMs reach AWS services over the public internet (
NO_INGRESSconnector, no VPC); the API is TLS-only behind API Gateway - WAF Rules: Implement AWS WAF rules for additional protection
- DDoS Protection: Use AWS Shield for DDoS protection
- Installation Guide
- Configuration Reference
- CLI Reference
- CI/CD Integration Examples
- Troubleshooting Guide
- Best Practices
- GitHub Issues: https://github.com/aws-samples/sample-nova-act-qa-studio/issues
- Documentation: https://github.com/aws-samples/sample-nova-act-qa-studio
If you discover a security vulnerability, please email security@example.com instead of using the public issue tracker.
Added:
GET/POST /applications/{id}/variables,GET/POST/DELETE /applications/{id}/secrets,GET/POST /applications/{id}/datasets,GET/PUT/DELETE /applications/{id}/datasets/{datasetId}(all via the application router;api/applications.read+ view,api/applications.write+ edit)dataset_idon repeat steps (XOR with inline rows; kept by export/import/clone; rejected on template steps)sourceon the rows ofGET /usecase/{id}/variablesandGET /usecase/{id}/secrets
Changed:
GET /usecase/{id}/variablesreturns the effective (application + use case) list;GET /usecase/{id}/secrets/{key}/valuefalls back to the application secretPOST /import,POST /usecase/{id}/clone:400withdatasetIdsfor unknown dataset references;POST /usecase/{id}/execute,POST /test-suites/{id}/execute:400 dataset not foundbefore any writePOST /applications/{id}/usecases:409when a moved use case references datasets;DELETE /applications/{id}also deletes the application's secrets
Breaking:
- Every team-owned resource route checks the caller's role in the owning team; orphan resources are Admin-only
POST /oauth-clientsandPOST /applicationsrequireteamId;POST /import,POST /wizard/startandPOST /templates/{id}/applyrequireapplication_id; wizardstep/commandrequireusecaseIdin the body (POST /wizard/{sessionId}/restartwas removed — the unified wizard has no restart)- OAuth clients without a team get 403 on team-owned resources;
api/adminandapi/teams.manageare never granted to or honored for clients POST /usecase/{id}/executeandGET /usecase/{id}/secrets/{key}/valuerequireapi/usecases.execute; trajectory download URLs requireapi/executions.read- Application create/update/delete: Team Lead of the owning team or Admin
- Team member endpoints reject
role: "admin"(400); the lastadminsmember can't be demoted or deleted (409) - More deletes return 204 (see Deletes return 204)
Added:
X-QA-Execution-Tokenheader for the cloud workerteam_id/team_roleon OAuth client responses
Added:
- OAuth client management endpoints (
POST /api/oauth-clients,GET /api/oauth-clients,DELETE /api/oauth-clients/{clientId},POST /api/oauth-clients/{clientId}/rotate-secret) - OAuth 2.0 client credentials flow documentation
- Scope validation and privilege escalation prevention
- Client secret rotation capability
- Complete CI/CD workflow examples in Bash and Python
Security:
- Client secrets only shown once at creation/rotation
- Immediate credential revocation on deletion
- Ownership verification for client management operations
- Audit logging for all OAuth client operations
Added:
- Test suite execution endpoint (
POST /test-suites/{id}/execute) - Support for base URL overrides
- Support for variable overrides with precedence
- Support for region and model_id overrides
- Structured logging and CloudWatch metrics
Initial Release:
- Execute usecase endpoint with
ci_runnertrigger type - Update execution step status endpoint
- Generate execution artifact presigned URL endpoint
- Generate step artifact presigned URL endpoint
- M2M token authentication support
| Scope | Resource | Permission | Description |
|---|---|---|---|
api/admin |
All | Full | Full access to all resources |
api/executions.write |
Executions | Write | Create and update executions |
api/executions.read |
Executions | Read | Read execution data |
api/usecases.write |
Usecases | Write | Create and update usecases |
api/usecases.read |
Usecases | Read | Read usecase data |
api/suite.write |
Test Suites | Write | Manage test suites |
api/suite.read |
Test Suites | Read | Read test suite data |
api/oauth-clients.write |
OAuth Clients | Write | Create, delete, and rotate OAuth clients |
api/oauth-clients.read |
OAuth Clients | Read | List OAuth clients |
| Status | Description | Terminal State |
|---|---|---|
pending |
Step has not started | No |
running |
Step is currently executing | No |
completed |
Step completed successfully | Yes |
failed |
Step failed with error | Yes |
skipped |
Step was skipped | Yes |
Execution-Level Artifacts:
| Type | Content Types | Description |
|---|---|---|
recording |
video/webm, video/mp4 |
Video recording of execution |
logs |
text/plain |
Execution logs |
Step-Level Artifacts:
| Type | Content Types | Description |
|---|---|---|
| Screenshot | image/png, image/jpeg |
Screenshot of step |
| Trace | application/json |
Playwright trace file |
| Trigger Type | Description | MicroVM started | Use Case |
|---|---|---|---|
OnDemandHeadless |
One Lambda MicroVM per execution | Yes | Default; UI-triggered execution |
Scheduled |
One Lambda MicroVM per execution | Yes | EventBridge scheduled |
ci_runner |
Record only | No | CI/CD integration |
These are the use-case trigger types (POST /usecase/{id}/execute). The suite endpoint accepts OnDemand,
scheduled and ci_runner.
Last Updated: 2024-02-16
API Version: 1.2.0
Document Version: 1.0.0