REST API Reference
Query and control ButterStack programmatically using the REST API.
Base URL
https://api.butterstack.com/v1
Authentication
All requests require a Bearer token in the Authorization header:
curl -H "Authorization: Bearer YOUR_API_KEY" \
https://api.butterstack.com/v1/projects
See Authentication Guide for details on obtaining API keys.
CLI Token Exchange
Used by the CLI to exchange a short-lived authorization code for an API token.
POST /v1/cli/token_exchange
Request Body:
{
"code": "auth_code_xyz",
"code_verifier": "the-pkce-verifier-that-produced-code_challenge"
}
code_verifier is required: the endpoint verifies SHA256(code_verifier) == code_challenge from the original /cli/auth request before it will exchange the code (PKCE, RFC 7636). A request without it always fails with invalid_grant.
Projects
List Projects
GET /v1/projects
Response:
{
"projects": [
{
"id": "proj_abc123",
"name": "MyGame",
"engine": "unreal",
"created_at": "2026-12-01T10:00:00Z"
}
]
}
Get Project
GET /v1/projects/:project_id
Errors
| Code | HTTP Status | Meaning |
|---|---|---|
unknown_parameter | 422 | A query parameter outside the allowed set for that action (neither endpoint takes any). |
Tasks
Note: The Tasks API manages ButterStack-native tasks. External issue tracker rows (e.g. Jira) surface via
ExternalTaskReferencerather than the native tasks endpoint.
Required Scopes
read:tasksforGETendpointswrite:tasksforPOSTandPATCHendpoints
List Tasks
GET /v1/projects/:project_id/tasks
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
limit | integer | Results per page (default: 20, clamped 1..50) |
cursor | string | Keyset cursor for the next page |
state | string | Filter by state(s), comma-separated: to_do,in_progress,blocked,completed |
task_type | string | Filter by task type: bug, feature, chore, art, content, balance |
priority | string | Filter by priority: low, medium, high, critical |
assignee | string | Filter by assignee: handle:<agent> or user:<id> |
label | string | Filter by label using JSONB containment |
updated_since | string | Filter tasks updated on or after an ISO8601 timestamp. Accepts a date-only value (2026-08-16), parsed as the beginning of that day. |
Errors
| Code | HTTP Status | Meaning |
|---|---|---|
unknown_parameter | 422 | A query parameter outside the allowed set above (limit, cursor, state, task_type, priority, assignee, label, updated_since, format). Also enforced on GET .../tasks/:id, which takes none. |
invalid_parameter_type | 422 | A normally-scalar parameter arrived as an array or nested hash, e.g. limit[]=5. |
invalid_state | 422 | state (comma-separated) contains a value outside Task.states. Strict: any invalid token 422s the whole request rather than filtering on the valid subset. Body includes valid_states. |
invalid_parameter | 422 | updated_since is present but does not parse to a date or timestamp. |
Response:
{
"tasks": [
{
"id": 123,
"account_id": 1,
"project_id": 42,
"title": "Fix character collision",
"description": "Fix collision box issue in Unreal",
"state": "in_progress",
"task_type": "bug",
"priority": "high",
"assignee_handle": "agent-smith",
"user_id": null,
"labels": ["gameplay", "physics"],
"metadata": { "client": { "cli_version": "1.2.0" } },
"created_at": "2026-08-15T12:00:00Z",
"updated_at": "2026-08-15T14:30:00Z",
"urls": {
"web": "https://app.butterstack.com/projects/42/tasks/123"
}
}
],
"pagination": {
"limit": 20,
"has_more": true,
"next_cursor": "MjAyNi0wOC0xNVQxNDozMDowMC4wMDAwMDBa"
}
}
Get Task
GET /v1/projects/:project_id/tasks/:id
Response:
{
"id": 123,
"account_id": 1,
"project_id": 42,
"title": "Fix character collision",
"description": "Fix collision box issue in Unreal",
"state": "in_progress",
"task_type": "bug",
"priority": "high",
"assignee_handle": "agent-smith",
"user_id": null,
"labels": ["gameplay", "physics"],
"metadata": { "client": { "cli_version": "1.2.0" }, "via_api_token_id": 5 },
"created_at": "2026-08-15T12:00:00Z",
"updated_at": "2026-08-15T14:30:00Z",
"urls": {
"web": "https://app.butterstack.com/projects/42/tasks/123"
},
"links": {
"changes": [
{
"id": 456,
"identifier": "1047",
"source_type": "Changelist",
"display_identifier": "#1047"
}
],
"build_runs": [
{
"id": 789,
"status": "completed",
"commit_hash": "p4-1047",
"ci_job_name": "Game-Build"
}
],
"deployments": [
{
"id": 321,
"status": "live",
"platform": "steam",
"branch": "main"
}
]
}
}
Create Task
POST /v1/projects/:project_id/tasks
Request Body:
{
"task": {
"title": "Implement double jump",
"description": "Add second jump physics and animation",
"task_type": "feature",
"priority": "medium",
"assignee_handle": "agent-jump",
"labels": ["gameplay", "movement"],
"metadata": {
"client": { "tool": "vscode" }
}
}
}
Update Task
PATCH /v1/projects/:project_id/tasks/:id
Request Body:
{
"task": {
"state": "completed",
"priority": "critical"
}
}
List Project Members
GET /v1/projects/:project_id/members
Requires read:members. Answers “which user_id may I assign?” for the task write path above (user_id / assignee_handle). No pagination – project teams are small; the response is capped (truncated: true if the member count exceeds it, with the list deterministically truncated by user_id).
Takes no filters – every query parameter is unknown and returns 422 unknown_parameter.
Known limitation, by design: this endpoint returns no display_name, name, or email – member names are deliberately not exposed by the API. user_id and assignee_handle are the functional identifiers the task write path accepts; identity resolution (“who is user 12?”) is not this endpoint’s job. connected_accounts[].uid is the only human-adjacent value in the payload, and a uid that is itself email-shaped is omitted for the same reason.
Response:
{
"members": [
{
"user_id": 12,
"role": "member",
"connected_accounts": [
{ "id": 4, "provider": "google_oauth2", "uid": "107812345678901234567" }
]
},
{
"user_id": 3,
"role": "owner",
"connected_accounts": []
}
],
"assignee_handles": ["agent-jump", "agent-smith"],
"truncated": false
}
assignee_handles is the set of free-form agent labels ([a-z0-9-]{1,50}, #1320) observed on the project’s tasks – observed usage, not an allowlist. user_id is a human member; assignee_handle is an agent label; the two are mutually exclusive on a task’s assignee.
Build Runs
Required Scopes
read:buildsforGETendpointswrite:buildsto trigger an AI failure investigation (POST .../investigate)
List Build Runs
GET /v1/projects/:project_id/build_runs
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
| status | string | Filter by status: pending, running, success, failed |
| build_type | string | Filter by build type |
| target_type | string | Filter by target type |
| limit | integer | Results per page (default: 20, clamped 1..50) |
Errors
| Code | HTTP Status | Meaning |
|---|---|---|
unknown_parameter | 422 | A query parameter outside the allowed set above. Also enforced on GET .../build_runs/:id and POST .../investigate, which take none. |
Response:
{
"build_runs": [ ... ],
"pagination": { "limit": 20, "has_more": false }
}
Get Build Run
GET /v1/projects/:project_id/build_runs/:build_run_id
Response:
{
"id": "build_xyz789",
"project_id": "proj_abc123",
"status": "success",
"duration_seconds": 1234,
"started_at": "2026-12-15T14:30:00Z",
"finished_at": "2026-12-15T14:50:34Z",
"changelist": "12345",
"artifacts": [
{
"name": "Game-Win64-Shipping.zip",
"size_bytes": 1234567890,
"url": "https://..."
}
]
}
Investigate a Failed Build
POST /v1/projects/:project_id/build_runs/:build_run_id/investigate
Only valid on a build in failed status. Triggers (or, if one is already pending/running, returns) an AI failure investigation via the account’s configured Build Investigator agent. This spends account credits (CreditService::AI_ACTION_COST) and is subject to the account’s daily intelligence limit – both enforced server-side, same as the equivalent web flow.
Response:
{
"investigation_id": "inv_abc123",
"status": "pending",
"diagnosis": null,
"suggested_fix": null,
"attributed_user_id": null,
"steps": []
}
Breaking change (2026-08-24, #1480):
attributed_authorwas removed. It could emit a raw commit-author email address whenever the author didn’t resolve to a ButterStack user, which violates the no-PII posture the rest of this API is held to (#1476-#1479, D-A). Consumers readingattributed_authormust migrate toattributed_user_id(the rawattributed_user_idinteger,nullwhen unattributed or when the commit author never resolved to a ButterStack user – there is no non-PII fallback string, by design).Triggering a new build run from the API (as opposed to investigating a failed one) is not yet implemented.
Assets
Required Scopes
read:assetsforGETendpointswrite:assetsforPOST .../approvals
List Assets
GET /v1/projects/:project_id/assets
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
| pending_approval | string | Pass "true" to only return assets with approval_status: pending |
| asset_type | string | Filter by asset type |
| limit | integer | Results per page (default: 20, clamped 1..50) |
Errors
| Code | HTTP Status | Meaning |
|---|---|---|
unknown_parameter | 422 | A query parameter outside the allowed set above. Also enforced on GET .../assets/:id, which takes none. |
Response:
{
"assets": [ ... ],
"pagination": { "limit": 20, "has_more": false }
}
Get Asset
GET /v1/projects/:project_id/assets/:asset_id
Includes an approvals_history array not present on the list response, each entry { id, status, approver_user_id, comments, decided_at }.
Breaking change (2026-08-24, #1480):
approvals_history[].approver(the approver’s name) was removed. Consumers must migrate toapprover_user_id(the rawapprover_idinteger, unchanged in shape and meaning from the Approvals endpoints below).
Submit Asset Approval
POST /v1/projects/:project_id/assets/:asset_id/approvals
Request Body:
{
"status": "approved",
"comments": "Looks good to me.",
"stage": "art_review"
}
status must be approved, denied, or ignored. stage defaults to art_review if omitted. Submitting a second decision for the same asset updates the existing approval record rather than creating a new one.
Approvals
Required Scopes
read:assetsforGETendpoints (approvals are asset decisions; the write leg above iswrite:assets)
List Approvals
GET /v1/projects/:project_id/approvals
Project-level and read-only – not the same route as Submit Asset Approval above, which is asset-nested and write-only.
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
status | string | Filter by status(es), comma-separated: pending, approved, denied, ignored |
stage | string | Filter by approval stage (e.g. art_review) |
commit_sha | string | Filter by the commit/changelist identifier the approval was requested against |
updated_since | string | Filter approvals updated on or after an ISO8601 timestamp. Accepts a date-only value (2026-08-16), parsed as the beginning of that day. |
limit | integer | Results per page (default: 20, clamped 1..50) |
cursor | string | Keyset cursor for the next page |
Errors
| Code | HTTP Status | Meaning |
|---|---|---|
unknown_parameter | 422 | A query parameter outside the allowed set above. Also enforced on GET .../approvals/:id, which takes none. |
invalid_parameter_type | 422 | A normally-scalar parameter arrived as an array or nested hash. |
invalid_status | 422 | status (comma-separated) contains a value outside pending, approved, denied, ignored. Strict: any invalid token 422s the whole request. Body includes valid_statuses. |
invalid_parameter | 422 | updated_since is present but does not parse to a date or timestamp. |
Invalid cursor parameter | 400 | cursor does not decode to a valid keyset position. |
Response:
{
"approvals": [
{
"id": "7c9e6679-...",
"asset_id": "b3e1f2a0-...",
"asset_name": "hero_texture.png",
"commit_sha": "1047",
"approval_stage": "art_review",
"approval_type": "manual",
"status": "approved",
"approver_user_id": 12,
"comments": "Approval requested in commit 1047: fix texture seams",
"asset_version": 3,
"approved_at": "2026-08-20T15:04:00Z",
"denied_at": null,
"ignored_at": null,
"decided_at": "2026-08-20T15:04:00Z",
"created_at": "2026-08-20T14:00:00Z",
"updated_at": "2026-08-20T15:04:00Z"
}
],
"pagination": {
"limit": 20,
"has_more": false,
"next_cursor": null
}
}
approver_user_id is the raw approver’s user_id – never a name. comments has any embedded email address redacted to its local part (commit messages routinely carry Co-Authored-By: trailers); the message body itself is kept, since it’s the diagnostic ITS uses.
This endpoint returns ButterStack’s persisted approval records; the web pending queue is served from a separate transient store, so a status=pending list here may differ from the Approvals page until #1161 unifies them.
Get Approval
GET /v1/projects/:project_id/approvals/:id
Scoped the same way as List Approvals – via assets.project_id, never a bare id lookup – so an id belonging to another project’s asset 404s rather than leaking cross-tenant. Same response shape as one entry of the approvals array above. Requires read:assets, same as the index.
Changes
Change covers commit/changelist history across every source rail (git, Perforce, Lore) – not just Perforce changelists, hence “changes” rather than “changelists” (matches the model’s Project#code_changes association and the links.changes key the Task detail endpoint already returns).
Required Scopes
read:changesforGETendpoints
List Changes
GET /v1/projects/:project_id/changes
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
identifier | string | Filter by exact identifier (SHA, or Perforce/Lore changelist number) |
source_type | string | Filter by source: AssetCommit, GitCommit, Changelist |
orphaned | string | Pass "true" to include orphaned (ghost commit) rows; excluded by default |
updated_since | string | Filter changes updated on or after an ISO8601 timestamp. Accepts a date-only value (2026-08-16), parsed as the beginning of that day. |
limit | integer | Results per page (default: 20, clamped 1..50) |
cursor | string | Keyset cursor for the next page |
Errors
| Code | HTTP Status | Meaning |
|---|---|---|
unknown_parameter | 422 | A query parameter outside the allowed set above. Also enforced on GET .../changes/:id, which takes none. |
invalid_parameter_type | 422 | A normally-scalar parameter arrived as an array or nested hash. |
invalid_parameter | 422 | updated_since is present but does not parse to a date or timestamp. |
Change not found | 404 | No change with that id in this project. |
Response:
{
"changes": [
{
"id": 456,
"identifier": "1047",
"display_identifier": "#1047",
"source_type": "Changelist",
"author": "jdoe",
"description": "Updated character model textures",
"timestamp": "2026-08-20T10:00:00Z",
"orphaned_at": null,
"created_at": "2026-08-20T10:00:05Z",
"updated_at": "2026-08-20T15:04:00Z",
"file_count": 3,
"approval_count": 2
}
],
"pagination": {
"limit": 20,
"has_more": false,
"next_cursor": null
}
}
author is sanitized: "Name <a@b.com>" becomes "Name", a bare "a@b.com" becomes "a" – the response never contains an @-bearing author string. description (the commit message) has any embedded email redacted to its local part the same way (Co-Authored-By: trailers are common). file_count is null, not 0, when file data genuinely isn’t available (git-sourced changes with no file metadata) – 0 would misleadingly read as “touched nothing”. approval_count is computed via a project-scoped, grouped query keyed on assets.project_id, never a bare commit_sha match: Perforce/Lore changelist numbers collide trivially across projects, so a bare match would leak another project’s approval count.
Get Change
GET /v1/projects/:project_id/changes/:id
Adds:
{
"files": [
{ "path": "//depot/Game/Content/Characters/Warrior/T_Warrior_D.uasset", "action": "edit" }
],
"files_available": true,
"approvals": { "pending": 0, "approved": 2, "denied": 0, "ignored": 0 },
"build_runs": [
{ "id": "a1b2c3d4-...", "status": "completed", "commit_hash": "p4-1047", "ci_job_name": "Game-Build" }
],
"latest_build": { "id": "a1b2c3d4-...", "status": "completed", "commit_hash": "p4-1047", "ci_job_name": "Game-Build" },
"task_ids": [123]
}
files_available is false only for a GitCommit-sourced change whose metadata carries no file list – files is then [] rather than a misleading empty-but-authoritative list.
Deployments
Required Scopes
read:buildsforGETendpoints (deployments are the build pipeline’s last leg)
List Deployments
GET /v1/projects/:project_id/deployments
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
status | string | Filter by status: pending, submitted, uploading, in_review, approved, rejected, live, rolled_back |
platform | string | Filter by platform: steam, epic_games_store, google_play, apple_app_store, unity_cloud, custom, reddit_devvit, youtube_playables |
build_run_id | string | Filter by the build run that produced the deployment |
updated_since | string | Filter deployments updated on or after an ISO8601 timestamp. Accepts a date-only value (2026-08-16), parsed as the beginning of that day. |
limit | integer | Results per page (default: 20, clamped 1..50) |
cursor | string | Keyset cursor for the next page |
Errors
| Code | HTTP Status | Meaning |
|---|---|---|
unknown_parameter | 422 | A query parameter outside the allowed set above. Also enforced on GET .../deployments/:id, which takes none. |
invalid_parameter_type | 422 | A normally-scalar parameter arrived as an array or nested hash. |
invalid_status | 422 | status is not one of the values listed above. Body includes valid_statuses. (Pre-existing, unchanged by this pass.) |
invalid_platform | 422 | platform is not one of the values listed above. Body includes valid_platforms. (Pre-existing, unchanged by this pass.) |
invalid_parameter | 422 | updated_since is present but does not parse to a date or timestamp. |
Invalid cursor parameter | 400 | cursor does not decode to a valid keyset position. |
Response:
{
"deployments": [
{
"id": "3f9e2b1a-...",
"platform": "steam",
"status": "live",
"branch": "public",
"version": "1.0.1",
"external_id": "480",
"external_build_id": "12346",
"build_run_id": "a1b2c3d4-...",
"integration_id": 7,
"submitted_at": "2026-12-15T13:00:00Z",
"deployed_at": "2026-12-15T14:00:00Z",
"live_at": "2026-12-15T15:00:00Z",
"created_at": "2026-12-15T13:00:00Z",
"updated_at": "2026-12-15T15:00:00Z"
}
],
"pagination": {
"limit": 20,
"has_more": false,
"next_cursor": null
}
}
deployments.id is a UUID, so the pagination cursor is not interchangeable with the integer-PK endpoints (Tasks). metadata and notes are never serialized: both are free-form and can carry a name or an email address, and neither has a schema guarantee.
Get Deployment
GET /v1/projects/:project_id/deployments/:id
Adds a build_run summary (id, status, commit_hash, ci_job_name) when the deployment has one, null otherwise – build_run_id can be nil (deployments ingested with no matching build run are a live case, not theoretical).
Read-Only Accounts
Write endpoints (POST/PATCH on Tasks, POST .../approvals, POST .../investigate) 403 with error: "demo_read_only" when the target project’s account is in demo mode. As of 2026-08-24 the body also carries an additive reason, so a caller can tell the two cases apart without guessing from the message text:
{
"error": "demo_read_only",
"reason": "trial_expired",
"message": "Your free trial has ended and your workspace is read-only. Add a payment method to reactivate.",
"upgrade_url": "https://app.butterstack.com/subscriptions"
}
reason | Meaning |
|---|---|
trial_expired | A real, signed-up account whose trial lapsed with no payment method and was automatically swept into read-only mode. upgrade_url is included; the message never says “sign up” or “demo” (the account is neither). |
demo | The seeded prospect-facing demo fixture, or any other demo-flagged account that isn’t a swept trial. upgrade_url is not included. |
error stays demo_read_only for both cases – it’s a shipped code and existing consumers match on it; reason is additive.
Error Responses
All errors follow this format:
{
"error": "error_code",
"message": "Human-readable description"
}
Common Error Codes
| Code | HTTP Status | Description |
|---|---|---|
| unauthorized | 401 | Invalid or missing auth token |
| forbidden | 403 | Token lacks required permissions |
| not_found | 404 | Resource not found |
| rate_limited | 429 | Too many requests |
| server_error | 500 | Internal server error |
Rate Limits
- Default: 60 requests/minute per API key
- Burst: Up to 10 requests/second
Rate limit headers in responses:
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 45
X-RateLimit-Reset: 1702656000
Related
- Authentication - API keys and tokens
- Webhooks - Inbound webhook endpoints