Link
GitHub Get Started

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

CodeHTTP StatusMeaning
unknown_parameter422A 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 ExternalTaskReference rather than the native tasks endpoint.

Required Scopes

  • read:tasks for GET endpoints
  • write:tasks for POST and PATCH endpoints

List Tasks

GET /v1/projects/:project_id/tasks

Query Parameters:

ParameterTypeDescription
limitintegerResults per page (default: 20, clamped 1..50)
cursorstringKeyset cursor for the next page
statestringFilter by state(s), comma-separated: to_do,in_progress,blocked,completed
task_typestringFilter by task type: bug, feature, chore, art, content, balance
prioritystringFilter by priority: low, medium, high, critical
assigneestringFilter by assignee: handle:<agent> or user:<id>
labelstringFilter by label using JSONB containment
updated_sincestringFilter tasks updated on or after an ISO8601 timestamp. Accepts a date-only value (2026-08-16), parsed as the beginning of that day.

Errors

CodeHTTP StatusMeaning
unknown_parameter422A 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_type422A normally-scalar parameter arrived as an array or nested hash, e.g. limit[]=5.
invalid_state422state (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_parameter422updated_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:builds for GET endpoints
  • write:builds to trigger an AI failure investigation (POST .../investigate)

List Build Runs

GET /v1/projects/:project_id/build_runs

Query Parameters:

ParameterTypeDescription
statusstringFilter by status: pending, running, success, failed
build_typestringFilter by build type
target_typestringFilter by target type
limitintegerResults per page (default: 20, clamped 1..50)

Errors

CodeHTTP StatusMeaning
unknown_parameter422A 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_author was 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 reading attributed_author must migrate to attributed_user_id (the raw attributed_user_id integer, null when 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:assets for GET endpoints
  • write:assets for POST .../approvals

List Assets

GET /v1/projects/:project_id/assets

Query Parameters:

ParameterTypeDescription
pending_approvalstringPass "true" to only return assets with approval_status: pending
asset_typestringFilter by asset type
limitintegerResults per page (default: 20, clamped 1..50)

Errors

CodeHTTP StatusMeaning
unknown_parameter422A 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 to approver_user_id (the raw approver_id integer, 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:assets for GET endpoints (approvals are asset decisions; the write leg above is write: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:

ParameterTypeDescription
statusstringFilter by status(es), comma-separated: pending, approved, denied, ignored
stagestringFilter by approval stage (e.g. art_review)
commit_shastringFilter by the commit/changelist identifier the approval was requested against
updated_sincestringFilter approvals updated on or after an ISO8601 timestamp. Accepts a date-only value (2026-08-16), parsed as the beginning of that day.
limitintegerResults per page (default: 20, clamped 1..50)
cursorstringKeyset cursor for the next page

Errors

CodeHTTP StatusMeaning
unknown_parameter422A query parameter outside the allowed set above. Also enforced on GET .../approvals/:id, which takes none.
invalid_parameter_type422A normally-scalar parameter arrived as an array or nested hash.
invalid_status422status (comma-separated) contains a value outside pending, approved, denied, ignored. Strict: any invalid token 422s the whole request. Body includes valid_statuses.
invalid_parameter422updated_since is present but does not parse to a date or timestamp.
Invalid cursor parameter400cursor 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:changes for GET endpoints

List Changes

GET /v1/projects/:project_id/changes

Query Parameters:

ParameterTypeDescription
identifierstringFilter by exact identifier (SHA, or Perforce/Lore changelist number)
source_typestringFilter by source: AssetCommit, GitCommit, Changelist
orphanedstringPass "true" to include orphaned (ghost commit) rows; excluded by default
updated_sincestringFilter changes updated on or after an ISO8601 timestamp. Accepts a date-only value (2026-08-16), parsed as the beginning of that day.
limitintegerResults per page (default: 20, clamped 1..50)
cursorstringKeyset cursor for the next page

Errors

CodeHTTP StatusMeaning
unknown_parameter422A query parameter outside the allowed set above. Also enforced on GET .../changes/:id, which takes none.
invalid_parameter_type422A normally-scalar parameter arrived as an array or nested hash.
invalid_parameter422updated_since is present but does not parse to a date or timestamp.
Change not found404No 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:builds for GET endpoints (deployments are the build pipeline’s last leg)

List Deployments

GET /v1/projects/:project_id/deployments

Query Parameters:

ParameterTypeDescription
statusstringFilter by status: pending, submitted, uploading, in_review, approved, rejected, live, rolled_back
platformstringFilter by platform: steam, epic_games_store, google_play, apple_app_store, unity_cloud, custom, reddit_devvit, youtube_playables
build_run_idstringFilter by the build run that produced the deployment
updated_sincestringFilter deployments updated on or after an ISO8601 timestamp. Accepts a date-only value (2026-08-16), parsed as the beginning of that day.
limitintegerResults per page (default: 20, clamped 1..50)
cursorstringKeyset cursor for the next page

Errors

CodeHTTP StatusMeaning
unknown_parameter422A query parameter outside the allowed set above. Also enforced on GET .../deployments/:id, which takes none.
invalid_parameter_type422A normally-scalar parameter arrived as an array or nested hash.
invalid_status422status is not one of the values listed above. Body includes valid_statuses. (Pre-existing, unchanged by this pass.)
invalid_platform422platform is not one of the values listed above. Body includes valid_platforms. (Pre-existing, unchanged by this pass.)
invalid_parameter422updated_since is present but does not parse to a date or timestamp.
Invalid cursor parameter400cursor 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"
}
reasonMeaning
trial_expiredA 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).
demoThe 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

CodeHTTP StatusDescription
unauthorized401Invalid or missing auth token
forbidden403Token lacks required permissions
not_found404Resource not found
rate_limited429Too many requests
server_error500Internal 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

Copyright © 2026 ButterStack. All rights reserved.

Esc
Type to search the documentation