Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

API Reference

Alloy exposes a REST API at http://localhost:3000 (default). When TLS is configured, the base URL becomes https://your-domain.com. All examples use curl with shell variables you can set once and reuse.

Common Setup

export BASE_URL="http://localhost:3000"  # or https://your-domain.com with TLS
export TOKEN="<your-access-token>"
export ORG_ID="<your-org-uuid>"
export PROJECT_ID="<your-project-uuid>"

Content Type

All request bodies are JSON. Set the header on every mutating request:

Content-Type: application/json

Authentication

Most endpoints require a Bearer token or API key in the Authorization header:

Authorization: Bearer $TOKEN

Public endpoints (no auth required): GET /health, POST /api/v1/auth/register, POST /api/v1/auth/login, GET /api/v1/onboard, POST /api/v1/onboard.

Roles and Permissions

Guide: Teams, Roles, and Permissions

Every organization member has one of five roles, listed from most to least privileged:

RoleDescription
OwnerFull control. Created automatically for the org creator.
AdminManage workflows, teams, invites, delete resources.
MemberCreate and update projects, tickets, sprints, time entries.
ReporterCreate tickets and comments. Limited to assigned projects.
ViewerRead-only access to all resources.

Permission Matrix

The table below shows the minimum role required for each operation. Any higher role also has access (e.g. Admin can do everything Member can). Read/list operations are available to all authenticated roles unless otherwise noted.

OperationMinimum Role
Orgs
Create / update orgOwner
Invites
Create / revoke inviteAdmin
List invitesAny authenticated
Workflows
Create / update / delete workflowAdmin
Teams
Delete teamAdmin
Projects
Create / update projectMember
Delete projectAdmin
Project Members
Add / remove project memberMember
List project membersAny authenticated
Tickets
Create ticketReporter (must be assigned to the project)
Update / transition ticketMember
Delete ticketAdmin
Comments
Create commentReporter
Update / delete commentAuthor or Admin
Sprints
Create / update / start / complete sprintMember
Delete sprintAdmin
Time Entries
Create / update / delete / submit time entryMember
Approve time entryAdmin
Labor Rates
All labor rate operationsAdmin

Project Membership (Reporter Scoping)

Reporters only see projects they are explicitly assigned to. When a Reporter calls GET /api/v1/projects, only assigned projects are returned. Calling GET /api/v1/projects/{id} or creating a ticket in an unassigned project returns 403.

Error Responses

All errors return a JSON body with this shape:

{
  "error": {
    "code": "not_found",
    "message": "Ticket not found",
    "details": []
  }
}
StatusMeaning
401Missing or invalid token
403Insufficient permissions (see Roles and Permissions)
404Resource not found
409Conflict — resource has dependents or duplicate key (see Delete Guards)
422Validation error (details array lists each field)
500Internal server error

403 Forbidden

Returned when the authenticated user’s role is insufficient for the operation.

{
  "error": {
    "code": "FORBIDDEN",
    "message": "requires Admin role or above",
    "details": []
  }
}

409 Conflict (Delete Guards)

Returned when deleting a resource that has dependents and cascade is not set. The details array lists each dependent type and its count.

{
  "error": {
    "code": "CONFLICT",
    "message": "Cannot delete project with existing dependents",
    "details": ["tickets: 5", "sprints: 2"]
  }
}

All DELETE endpoints that enforce delete guards accept these query parameters:

ParameterTypeDefaultDescription
cascadebooleanfalseDelete all dependents recursively
dry_runbooleanfalseWith cascade=true, return dependency counts without deleting

A cascade dry-run (?cascade=true&dry_run=true) returns 200 OK with a body listing what would be deleted. See each DELETE endpoint for its specific response shape.

Pagination

List endpoints use cursor-based pagination. Pass cursor and limit as query parameters. Responses include:

{
  "items": [],
  "next_cursor": "abc123",
  "has_more": true
}
ParameterTypeDefaultMaxDescription
cursorstringOpaque cursor from a previous response
limitinteger20100Number of items per page

Auth

Register

Create a new user account.

POST /api/v1/auth/register — no auth required

FieldTypeRequiredDescription
emailstringyesValid email address
passwordstringyesMinimum 8 characters
display_namestringyes1–255 characters
invite_codestringnoRequired when registration is invite-only
curl -s "$BASE_URL/api/v1/auth/register" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "alice@example.com",
    "password": "changeme123",
    "display_name": "Alice"
  }' | jq .
{
  "access_token": "eyJ...",
  "refresh_token": "def...",
  "user_id": "550e8400-...",
  "email": "alice@example.com",
  "display_name": "Alice"
}

Login

Authenticate an existing user.

POST /api/v1/auth/login — no auth required

FieldTypeRequiredDescription
emailstringyesEmail address
passwordstringyesPassword
curl -s "$BASE_URL/api/v1/auth/login" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "alice@example.com",
    "password": "changeme123"
  }' | jq .
{
  "access_token": "eyJ...",
  "refresh_token": "def...",
  "user_id": "550e8400-...",
  "email": "alice@example.com",
  "display_name": "Alice"
}

Refresh Token

Exchange a refresh token for new tokens.

POST /api/v1/auth/refresh

FieldTypeRequiredDescription
refresh_tokenstringyesRefresh token from login/register
curl -s "$BASE_URL/api/v1/auth/refresh" \
  -H "Content-Type: application/json" \
  -d "{\"refresh_token\": \"$REFRESH_TOKEN\"}" | jq .
{
  "access_token": "eyJ...",
  "refresh_token": "ghi...",
  "user_id": "550e8400-...",
  "email": "alice@example.com",
  "display_name": "Alice"
}

Logout

Revoke a refresh token.

POST /api/v1/auth/logout

FieldTypeRequiredDescription
refresh_tokenstringyesToken to revoke
curl -s -X POST "$BASE_URL/api/v1/auth/logout" \
  -H "Content-Type: application/json" \
  -d "{\"refresh_token\": \"$REFRESH_TOKEN\"}"

Returns 204 No Content on success.

Get Current User

Return the authenticated user’s profile and permissions.

GET /api/v1/auth/me — requires auth

curl -s "$BASE_URL/api/v1/auth/me" \
  -H "Authorization: Bearer $TOKEN" | jq .
{
  "user_id": "550e8400-...",
  "org_id": "660e8400-...",
  "email": "alice@example.com",
  "role": "Owner",
  "scopes": "...",
  "allowed_project_ids": "..."
}

Create API Key

Generate a long-lived API key for programmatic access.

POST /api/v1/api-keys — requires auth

FieldTypeRequiredDescription
namestringyesKey name, 1–255 characters
scopesstring[]no["read", "write"] (default) or ["admin"]
project_idsstring[]noRestrict key to specific projects
curl -s "$BASE_URL/api/v1/api-keys" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "CI Key", "scopes": ["read", "write"]}' | jq .
{
  "id": "770e8400-...",
  "name": "CI Key",
  "key": "alloy_live_abc123...",
  "key_prefix": "alloy_live_...",
  "scopes": ["read", "write"],
  "project_ids": [],
  "created_at": "2026-03-28-...",
  "expires_at": null
}

List API Keys

GET /api/v1/api-keys — requires auth

curl -s "$BASE_URL/api/v1/api-keys" \
  -H "Authorization: Bearer $TOKEN" | jq .
[
  {
    "id": "770e8400-...",
    "name": "CI Key",
    "key_prefix": "alloy_live_...",
    "scopes": ["read", "write"],
    "project_ids": [],
    "created_at": "2026-03-28-...",
    "last_used_at": null,
    "expires_at": null
  }
]

Delete API Key

DELETE /api/v1/api-keys/{id} — requires auth

curl -s -X DELETE "$BASE_URL/api/v1/api-keys/$API_KEY_ID" \
  -H "Authorization: Bearer $TOKEN"

Returns 204 No Content.


Onboarding

Check Onboarding Status

GET /api/v1/onboard — no auth required

curl -s "$BASE_URL/api/v1/onboard" | jq .

Returns {"needs_onboarding": true} when no users exist, or {"needs_onboarding": false} otherwise.

Run Onboarding

Create the first organization and admin user. Only available when no orgs exist.

POST /api/v1/onboard — no auth required

FieldTypeRequiredDescription
emailstringyesAdmin email
passwordstringyesMinimum 8 characters
org_namestringyesOrganization name, 1–255 characters
org_slugstringyesOrganization slug, 1–100 characters
curl -s "$BASE_URL/api/v1/onboard" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "admin@example.com",
    "password": "changeme123",
    "org_name": "Acme Corp",
    "org_slug": "acme"
  }' | jq .

Returns org_id, user_id, email, api_key, and api_key_prefix on success. Returns 409 Conflict if onboarding was already completed.


Orgs

Create Organization

POST /api/v1/orgs — requires auth

FieldTypeRequiredDescription
namestringyes1–255 characters
slugstringyes1–100 characters
curl -s "$BASE_URL/api/v1/orgs" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "Acme Corp", "slug": "acme"}' | jq .
{
  "id": "660e8400-...",
  "name": "Acme Corp",
  "slug": "acme",
  "created_at": "2026-03-28-...",
  "updated_at": "2026-03-28-..."
}

List Organizations

GET /api/v1/orgs — requires auth

curl -s "$BASE_URL/api/v1/orgs" \
  -H "Authorization: Bearer $TOKEN" | jq .
{
  "items": [
    {
      "id": "660e8400-...",
      "name": "...",
      "slug": "...",
      "created_at": "2026-03-28-...",
      "updated_at": "2026-03-28-..."
    }
  ]
}

Add Member to Organization

POST /api/v1/orgs/{id}/members — requires auth

FieldTypeRequiredDescription
user_idstringyesUUID of user to add
rolestringnoowner, admin, member (default), or viewer
curl -s "$BASE_URL/api/v1/orgs/$ORG_ID/members" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d "{\"user_id\": \"$USER_ID\", \"role\": \"member\"}" | jq .

Returns the membership object with user_id, org_id, role, and joined_at. Returns 409 Conflict if the user is already a member.

List Organization Members

GET /api/v1/orgs/{id}/members — requires auth

curl -s "$BASE_URL/api/v1/orgs/$ORG_ID/members" \
  -H "Authorization: Bearer $TOKEN" | jq .
{
  "items": [
    {
      "user_id": "550e8400-...",
      "display_name": "Alice",
      "email": "alice@example.com",
      "role": "Owner",
      "joined_at": "2026-03-28-..."
    }
  ]
}

Create Invite

Generate an invite link to join an organization.

POST /api/v1/orgs/{org_id}/invites — requires auth

FieldTypeRequiredDescription
emailstringnoEmail to invite
rolestringnoRole to assign (default: member)
created_bystringno(deprecated, ignored) Derived from auth token
curl -s "$BASE_URL/api/v1/orgs/$ORG_ID/invites" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"email": "bob@example.com", "role": "member"}' | jq .

Returns the invite object with id, invite_code, invite_link, email, role, and expires_at.

List Invites

GET /api/v1/orgs/{org_id}/invites — requires auth

curl -s "$BASE_URL/api/v1/orgs/$ORG_ID/invites" \
  -H "Authorization: Bearer $TOKEN" | jq .

Returns an array of invite objects for the organization.

Revoke Invite

DELETE /api/v1/orgs/{org_id}/invites/{id} — requires auth

curl -s -X DELETE "$BASE_URL/api/v1/orgs/$ORG_ID/invites/$INVITE_ID" \
  -H "Authorization: Bearer $TOKEN"

Returns 204 No Content.


Projects

Guide: Projects and Tickets

Create Project

POST /api/v1/projects — requires auth

FieldTypeRequiredDescription
org_idstringyesOrganization UUID
keystringyesShort project key, 1–10 chars (e.g. PROJ)
namestringyes1–255 characters
descriptionstringnoProject description
team_idstringnoTeam UUID
budget_centsintegernoBudget amount in cents
budget_periodstringnoBudget period: Monthly, Quarterly, Yearly, or Fixed
curl -s "$BASE_URL/api/v1/projects" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d "{
    \"org_id\": \"$ORG_ID\",
    \"key\": \"PROJ\",
    \"name\": \"My Project\",
    \"description\": \"A sample project\"
  }" | jq .
{
  "id": "990e8400-...",
  "org_id": "660e8400-...",
  "team_id": null,
  "workflow_id": null,
  "key": "PROJ",
  "name": "My Project",
  "description": "A sample project",
  "ticket_counter": 0,
  "capitalization_type": null,
  "development_phase": null,
  "cost_center_id": null,
  "amortization_months": null,
  "budget_cents": null,
  "budget_period": null,
  "created_at": "2026-03-28-...",
  "updated_at": "2026-03-28-..."
}

List Projects

GET /api/v1/projects?org_id={org_id} — requires auth

Query ParamTypeRequiredDescription
org_idstringyesOrganization UUID
cursorstringnoPagination cursor
limitintegernoPage size (default 20, max 100)
curl -s "$BASE_URL/api/v1/projects?org_id=$ORG_ID" \
  -H "Authorization: Bearer $TOKEN" | jq .
{
  "items": [
    {
      "id": "990e8400-...",
      "org_id": "660e8400-...",
      "key": "PROJ",
      "name": "My Project",
      "description": "A sample project",
      "ticket_counter": 0,
      "created_at": "2026-03-28-...",
      "updated_at": "2026-03-28-..."
    }
  ],
  "next_cursor": null,
  "has_more": false
}

Get Project by ID

GET /api/v1/projects/{id} — requires auth

curl -s "$BASE_URL/api/v1/projects/$PROJECT_ID" \
  -H "Authorization: Bearer $TOKEN" | jq .

Returns a single ProjectResponse.

Get Project by Key

GET /api/v1/projects/key/{key} — requires auth

ParameterTypeRequiredDescription
org_idstring (query)noOrganization ID to scope the lookup. Defaults to auth token’s org.
curl -s "$BASE_URL/api/v1/projects/key/PROJ?org_id=$ORG_ID" \
  -H "Authorization: Bearer $TOKEN" | jq .

Returns a single ProjectResponse.

Update Project

PATCH /api/v1/projects/{id} — requires auth

FieldTypeRequiredDescription
namestringnoNew name
descriptionstring | nullnoNew description or null to clear
team_idstring | nullnoNew team or null to unset
capitalization_typestring | nullnoCapitalization type
development_phasestring | nullnoDevelopment phase
cost_center_idstring | nullnoCost center ID
amortization_monthsinteger | nullnoAmortization months
budget_centsinteger | nullnoBudget amount in cents, or null to clear
budget_periodstring | nullnoMonthly, Quarterly, Yearly, Fixed, or null to clear
curl -s -X PATCH "$BASE_URL/api/v1/projects/$PROJECT_ID" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "Renamed Project"}' | jq .

Returns the updated ProjectResponse.

Delete Project

DELETE /api/v1/projects/{id} — requires Admin

Returns 204 No Content if the project has no dependents.

Returns 409 Conflict if the project has tickets or sprints. Use ?cascade=true to delete all dependents, or ?cascade=true&dry_run=true to preview what would be deleted.

ParameterTypeDefaultDescription
cascadebooleanfalseDelete all tickets, sprints, and comments
dry_runbooleanfalseWith cascade, return counts without deleting

Cascade dry-run response (?cascade=true&dry_run=true):

{
  "project_id": "...",
  "dependents": {
    "tickets": 5,
    "sprints": 2,
    "comments": 12
  }
}

409 response (no cascade, has dependents):

{
  "error": {
    "code": "CONFLICT",
    "message": "Cannot delete project with existing dependents",
    "details": ["tickets: 5", "sprints: 2"]
  }
}
curl -s -X DELETE "$BASE_URL/api/v1/projects/$PROJECT_ID" \
  -H "Authorization: Bearer $TOKEN"

Returns 204 No Content on success.


Project Members

Guide: Teams, Roles, and Permissions

Manage which users are assigned to a project. Reporters can only access projects they are members of. Other roles (Member and above) see all projects regardless of membership.

Add Project Member

POST /api/v1/projects/{project_id}/members — requires Member

FieldTypeRequiredDescription
user_idstringyesUser UUID to add
{
  "project_id": "...",
  "user_id": "...",
  "created_at": "..."
}

Returns 201 Created.

List Project Members

GET /api/v1/projects/{project_id}/members — requires auth

Returns an array of project membership objects:

[
  {
    "project_id": "...",
    "user_id": "...",
    "created_at": "..."
  }
]

Remove Project Member

DELETE /api/v1/projects/{project_id}/members/{user_id} — requires Member

Returns 204 No Content.


Tickets

Guides: Projects and Tickets | Workflows and Statuses

Create Ticket

POST /api/v1/projects/{project_id}/tickets — requires auth

FieldTypeRequiredDescription
titlestringyes1–500 characters
descriptionstringnoTicket description
statusstringnoBacklog (default), Todo, InProgress, InReview, Done, Cancelled
prioritystringnoNone (default), Low, Medium, High, Critical
assignee_idstringnoUser UUID
reporter_idstringno(deprecated, ignored) Derived from auth token
curl -s "$BASE_URL/api/v1/projects/$PROJECT_ID/tickets" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d "{
    \"title\": \"Fix login bug\",
    \"description\": \"Users can't log in with SSO\",
    \"priority\": \"High\"
  }" | jq .
{
  "id": "aa0e8400-...",
  "project_id": "990e8400-...",
  "ticket_number": 1,
  "title": "Fix login bug",
  "description": "Users can't log in with SSO",
  "status": "Backlog",
  "priority": "High",
  "assignee_id": null,
  "reporter_id": "550e8400-...",
  "sprint_id": null,
  "created_at": "2026-03-28-...",
  "updated_at": "2026-03-28-..."
}

List Tickets

GET /api/v1/projects/{project_id}/tickets — requires auth

Query ParamTypeRequiredDescription
statusstringnoFilter by status
prioritystringnoFilter by priority
assignee_idstringnoFilter by assignee
cursorstringnoPagination cursor
limitintegernoPage size (default 20, max 100)
curl -s "$BASE_URL/api/v1/projects/$PROJECT_ID/tickets?status=Backlog" \
  -H "Authorization: Bearer $TOKEN" | jq .
{
  "items": [
    {
      "id": "aa0e8400-...",
      "project_id": "990e8400-...",
      "ticket_number": 1,
      "title": "Fix login bug",
      "status": "Backlog",
      "priority": "High",
      "created_at": "2026-03-28-...",
      "updated_at": "2026-03-28-..."
    }
  ],
  "next_cursor": null,
  "has_more": false
}

Get Ticket

GET /api/v1/tickets/{id} — requires auth

curl -s "$BASE_URL/api/v1/tickets/$TICKET_ID" \
  -H "Authorization: Bearer $TOKEN" | jq .

Returns a single TicketResponse.

Resolve Ticket Reference

Look up a ticket by UUID, key-number (e.g. PROJ-42), or bare number.

GET /api/v1/tickets/resolve?ref={ref} — requires auth

Query ParamTypeRequiredDescription
refstringyesUUID, KEY-NUMBER, or bare number
project_idstringnoRequired when using bare number
org_idstringnoOrganization ID to scope key lookup. Defaults to auth token’s org.
curl -s "$BASE_URL/api/v1/tickets/resolve?ref=PROJ-1&org_id=$ORG_ID" \
  -H "Authorization: Bearer $TOKEN" | jq .
{
  "ticket": {
    "id": "aa0e8400-...",
    "ticket_number": 1,
    "title": "Fix login bug"
  },
  "suggestions": []
}

Update Ticket

PATCH /api/v1/tickets/{id} — requires auth

FieldTypeRequiredDescription
titlestringnoNew title
descriptionstring | nullnoNew description or null
statusstringnoNew status
prioritystringnoNew priority
assignee_idstring | nullnoNew assignee or null
curl -s -X PATCH "$BASE_URL/api/v1/tickets/$TICKET_ID" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"status": "InProgress", "priority": "Critical"}' | jq .

Returns the updated TicketResponse.

Delete Ticket

DELETE /api/v1/tickets/{id} — requires Admin

Returns 204 No Content if the ticket has no dependents.

Returns 409 Conflict if the ticket has comments. Use ?cascade=true to delete all dependents, or ?cascade=true&dry_run=true to preview.

ParameterTypeDefaultDescription
cascadebooleanfalseDelete all comments
dry_runbooleanfalseWith cascade, return counts without deleting

Cascade dry-run response (?cascade=true&dry_run=true):

{
  "ticket_id": "...",
  "dependents": {
    "comments": 3
  }
}

409 response (no cascade, has dependents):

{
  "error": {
    "code": "CONFLICT",
    "message": "Cannot delete ticket with existing dependents",
    "details": ["comments: 3"]
  }
}

Batch Update Tickets

Apply the same field updates to multiple tickets at once. Accepts an array of ticket IDs and the fields to update. Returns the count of updated tickets. Requires Member role.

POST /api/v1/tickets/batch-update — requires Member

FieldTypeRequiredDescription
ticket_idsstring[]yesArray of ticket UUIDs to update
titlestringnoNew title for all tickets
descriptionstring|nullnoNew description (null to clear)
statusstringnoNew status
prioritystringnoNew priority
assignee_idstring|nullnoAssignee UUID (null to clear)
sprint_idstring|nullnoSprint UUID (null to clear)
curl -s -X POST "$BASE_URL/api/v1/tickets/batch-update" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"ticket_ids":["'"$TICKET_ID"'"],"status":"Done"}'
{
  "updated": 1
}

Batch Delete Tickets

Delete multiple tickets at once. Supports cascade deletion (removes comments) and dry-run mode (reports what would happen without deleting). Tickets with dependents are blocked unless cascade is true. Requires Admin role.

POST /api/v1/tickets/batch-delete — requires Admin

FieldTypeRequiredDescription
ticket_idsstring[]yesArray of ticket UUIDs to delete
cascadebooleannoIf true, delete dependents (comments) too. Default: false
dry_runbooleannoIf true, report what would happen without deleting. Default: false
curl -s -X POST "$BASE_URL/api/v1/tickets/batch-delete" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"ticket_ids":["'"$TICKET_ID"'"],"cascade":true,"dry_run":true}'
{
  "deleted": ["..."],
  "blocked": []
}

When tickets have dependents and cascade is false, they appear in blocked:

{
  "deleted": [],
  "blocked": [
    {
      "id": "...",
      "dependents": {
        "comments": 3
      }
    }
  ]
}

Transition Ticket Status

Transition a ticket through a workflow. Validates the transition against the workflow’s rules and enforcement mode (see Enforcement Modes).

POST /api/v1/tickets/{id}/transition — requires auth

FieldTypeRequiredDescription
to_statusstringyesTarget status
curl -s "$BASE_URL/api/v1/tickets/$TICKET_ID/transition" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"to_status": "InReview"}' | jq .

Returns the updated TicketResponse.

Get Available Transitions

GET /api/v1/tickets/{id}/available-transitions — requires auth

curl -s "$BASE_URL/api/v1/tickets/$TICKET_ID/available-transitions" \
  -H "Authorization: Bearer $TOKEN" | jq .

Get Ticket Activity

GET /api/v1/tickets/{ticket_id}/activity — requires auth

curl -s "$BASE_URL/api/v1/tickets/$TICKET_ID/activity" \
  -H "Authorization: Bearer $TOKEN" | jq .
{
  "items": [
    {
      "type": "...",
      "actor": "550e8400-...",
      "timestamp": "2026-03-28-...",
      "payload": {
        "audit_log_id": "...",
        "action": "...",
        "entity_type": "...",
        "changes": [
          {
            "field": "...",
            "old": "...",
            "new": "..."
          }
        ]
      }
    }
  ],
  "next_cursor": null,
  "has_more": false
}

Comments

Guide: Projects and Tickets

Create Comment

POST /api/v1/tickets/{ticket_id}/comments — requires auth

FieldTypeRequiredDescription
bodystringyesComment text (min 1 char)
author_idstringno(deprecated, ignored) Derived from auth token
parent_comment_idstringnoParent comment UUID for replies
curl -s "$BASE_URL/api/v1/tickets/$TICKET_ID/comments" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d "{
    \"body\": \"This needs investigation.\"
  }" | jq .
{
  "id": "bb0e8400-...",
  "ticket_id": "aa0e8400-...",
  "author_id": "550e8400-...",
  "body": "This needs investigation.",
  "parent_comment_id": null,
  "created_at": "2026-03-28-...",
  "updated_at": "2026-03-28-..."
}

List Comments

GET /api/v1/tickets/{ticket_id}/comments — requires auth

ParameterTypeDefaultMaxDescription
cursorstringOpaque cursor from a previous response
limitinteger20100Number of items per page
curl -s "$BASE_URL/api/v1/tickets/$TICKET_ID/comments" \
  -H "Authorization: Bearer $TOKEN" | jq .
{
  "items": [
    {
      "id": "bb0e8400-...",
      "ticket_id": "aa0e8400-...",
      "author_id": "550e8400-...",
      "body": "This needs investigation.",
      "parent_comment_id": null,
      "created_at": "2026-03-28-...",
      "updated_at": "2026-03-28-..."
    }
  ],
  "next_cursor": null,
  "has_more": false
}

Get Comment

GET /api/v1/comments/{id} — requires auth

curl -s "$BASE_URL/api/v1/comments/$COMMENT_ID" \
  -H "Authorization: Bearer $TOKEN" | jq .
{
  "id": "bb0e8400-...",
  "ticket_id": "aa0e8400-...",
  "author_id": "550e8400-...",
  "body": "This needs investigation.",
  "parent_comment_id": null,
  "created_at": "2026-03-28-...",
  "updated_at": "2026-03-28-..."
}

Update Comment

PATCH /api/v1/comments/{id} — requires auth

FieldTypeRequiredDescription
bodystringyesUpdated comment text
curl -s -X PATCH "$BASE_URL/api/v1/comments/$COMMENT_ID" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"body": "Updated comment text."}' | jq .
{
  "id": "bb0e8400-...",
  "ticket_id": "aa0e8400-...",
  "author_id": "550e8400-...",
  "body": "Updated comment text.",
  "parent_comment_id": null,
  "created_at": "2026-03-28-...",
  "updated_at": "2026-03-28-..."
}

Delete Comment

DELETE /api/v1/comments/{id} — requires Author or Admin

Only the comment author or an Admin+ can delete a comment. Returns 403 if another non-admin user attempts to delete.

curl -s -X DELETE "$BASE_URL/api/v1/comments/$COMMENT_ID" \
  -H "Authorization: Bearer $TOKEN"

Returns 204 No Content.


Labels

Guide: Labels, Tags, and Organization

Create Label

POST /api/v1/orgs/{org_id}/labels — requires auth

FieldTypeRequiredDescription
namestringyesLabel name (min 1 char)
colorstringyesHex color, 4–7 chars (e.g. #FF5733)
curl -s "$BASE_URL/api/v1/orgs/$ORG_ID/labels" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "bug", "color": "#FF0000"}' | jq .
{
  "id": "cc0e8400-...",
  "org_id": "660e8400-...",
  "name": "bug",
  "color": "#FF0000",
  "created_at": "2026-03-28-...",
  "updated_at": "2026-03-28-..."
}

List Labels

GET /api/v1/orgs/{org_id}/labels — requires auth

ParameterTypeDefaultMaxDescription
cursorstringOpaque cursor from a previous response
limitinteger20100Number of items per page
curl -s "$BASE_URL/api/v1/orgs/$ORG_ID/labels" \
  -H "Authorization: Bearer $TOKEN" | jq .
{
  "items": [
    {
      "id": "cc0e8400-...",
      "org_id": "660e8400-...",
      "name": "bug",
      "color": "#FF0000",
      "created_at": "2026-03-28-...",
      "updated_at": "2026-03-28-..."
    }
  ],
  "next_cursor": null,
  "has_more": false
}

Get Label

GET /api/v1/labels/{id} — requires auth

curl -s "$BASE_URL/api/v1/labels/$LABEL_ID" \
  -H "Authorization: Bearer $TOKEN" | jq .
{
  "id": "cc0e8400-...",
  "org_id": "660e8400-...",
  "name": "bug",
  "color": "#FF0000",
  "created_at": "2026-03-28-...",
  "updated_at": "2026-03-28-..."
}

Update Label

PATCH /api/v1/labels/{id} — requires auth

FieldTypeRequiredDescription
namestringnoNew label name
colorstringnoNew color
curl -s -X PATCH "$BASE_URL/api/v1/labels/$LABEL_ID" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"color": "#00FF00"}' | jq .
{
  "id": "cc0e8400-...",
  "org_id": "660e8400-...",
  "name": "bug",
  "color": "#00FF00",
  "created_at": "2026-03-28-...",
  "updated_at": "2026-03-28-..."
}

Batch Assign Label

Assign a label to multiple tickets at once. The label is applied to each ticket in the list. Idempotent — assigning a label that is already present has no effect. Requires Member role.

POST /api/v1/labels/{id}/batch-assign — requires Member

FieldTypeRequiredDescription
ticket_idsstring[]yesArray of ticket UUIDs to apply the label to
curl -s -X POST "$BASE_URL/api/v1/labels/$LABEL_ID/batch-assign" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"ticket_ids":["'"$TICKET_ID"'"]}'
{
  "assigned": 1
}

Delete Label

DELETE /api/v1/labels/{id} — requires auth

Returns 204 No Content if the label has no dependents.

Returns 409 Conflict if tickets are using this label. Use ?cascade=true to remove all ticket-label associations, or ?cascade=true&dry_run=true to preview.

ParameterTypeDefaultDescription
cascadebooleanfalseRemove all ticket-label associations
dry_runbooleanfalseWith cascade, return counts without deleting

Cascade dry-run response (?cascade=true&dry_run=true):

{
  "label_id": "...",
  "dependents": {
    "tickets": 4
  }
}
curl -s -X DELETE "$BASE_URL/api/v1/labels/$LABEL_ID" \
  -H "Authorization: Bearer $TOKEN"

Returns 204 No Content on success.

Set Ticket Labels

Replace all labels on a ticket.

POST /api/v1/tickets/{ticket_id}/labels — requires auth

FieldTypeRequiredDescription
label_idsstring[]yesLabel UUIDs to set
curl -s "$BASE_URL/api/v1/tickets/$TICKET_ID/labels" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d "{\"label_ids\": [\"$LABEL_ID\"]}" | jq .
[
  {
    "id": "cc0e8400-...",
    "org_id": "660e8400-...",
    "name": "...",
    "color": "...",
    "created_at": "2026-03-28-...",
    "updated_at": "2026-03-28-..."
  }
]

List Ticket Labels

GET /api/v1/tickets/{ticket_id}/labels — requires auth

curl -s "$BASE_URL/api/v1/tickets/$TICKET_ID/labels" \
  -H "Authorization: Bearer $TOKEN" | jq .
[
  {
    "id": "cc0e8400-...",
    "org_id": "660e8400-...",
    "name": "...",
    "color": "...",
    "created_at": "2026-03-28-...",
    "updated_at": "2026-03-28-..."
  }
]

Add Label to Ticket

POST /api/v1/tickets/{ticket_id}/labels/{label_id} — requires auth

curl -s -X POST "$BASE_URL/api/v1/tickets/$TICKET_ID/labels/$LABEL_ID" \
  -H "Authorization: Bearer $TOKEN" | jq .
[
  {
    "id": "cc0e8400-...",
    "org_id": "660e8400-...",
    "name": "...",
    "color": "...",
    "created_at": "2026-03-28-...",
    "updated_at": "2026-03-28-..."
  }
]

Remove Label from Ticket

DELETE /api/v1/tickets/{ticket_id}/labels/{label_id} — requires auth

curl -s -X DELETE "$BASE_URL/api/v1/tickets/$TICKET_ID/labels/$LABEL_ID" \
  -H "Authorization: Bearer $TOKEN"

Returns 204 No Content.


Sprints

Guide: Sprints and Boards

Create Sprint

POST /api/v1/projects/{project_id}/sprints — requires auth

FieldTypeRequiredDescription
namestringyesSprint name (min 1 char)
goalstringnoSprint goal
start_datestringyesISO 8601 date
end_datestringyesISO 8601 date
curl -s "$BASE_URL/api/v1/projects/$PROJECT_ID/sprints" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Sprint 1",
    "goal": "Complete MVP",
    "start_date": "2026-03-28",
    "end_date": "2026-04-11"
  }' | jq .
{
  "id": "dd0e8400-...",
  "project_id": "990e8400-...",
  "name": "Sprint 1",
  "goal": "Complete MVP",
  "start_date": "2026-03-28",
  "end_date": "2026-04-11",
  "status": "Planned",
  "created_at": "2026-03-28-...",
  "updated_at": "2026-03-28-..."
}

List Sprints

GET /api/v1/projects/{project_id}/sprints — requires auth

ParameterTypeDefaultMaxDescription
cursorstringOpaque cursor from a previous response
limitinteger20100Number of items per page
curl -s "$BASE_URL/api/v1/projects/$PROJECT_ID/sprints" \
  -H "Authorization: Bearer $TOKEN" | jq .
{
  "items": [
    {
      "id": "dd0e8400-...",
      "project_id": "990e8400-...",
      "name": "Sprint 1",
      "goal": "Complete MVP",
      "start_date": "2026-03-28",
      "end_date": "2026-04-11",
      "status": "Planned",
      "created_at": "2026-03-28-...",
      "updated_at": "2026-03-28-..."
    }
  ],
  "next_cursor": null,
  "has_more": false
}

Get Sprint

GET /api/v1/sprints/{id} — requires auth

curl -s "$BASE_URL/api/v1/sprints/$SPRINT_ID" \
  -H "Authorization: Bearer $TOKEN" | jq .
{
  "id": "dd0e8400-...",
  "project_id": "990e8400-...",
  "name": "Sprint 1",
  "goal": "Complete MVP",
  "start_date": "2026-03-28",
  "end_date": "2026-04-11",
  "status": "Planned",
  "created_at": "2026-03-28-...",
  "updated_at": "2026-03-28-..."
}

Update Sprint

PATCH /api/v1/sprints/{id} — requires auth

FieldTypeRequiredDescription
namestringnoNew sprint name
goalstringnoNew sprint goal (pass null to clear)
start_datestringnoNew start date
end_datestringnoNew end date
curl -s -X PATCH "$BASE_URL/api/v1/sprints/$SPRINT_ID" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"goal": "Ship auth and tickets"}' | jq .
{
  "id": "dd0e8400-...",
  "project_id": "990e8400-...",
  "name": "Sprint 1",
  "goal": "Ship auth and tickets",
  "start_date": "2026-03-28",
  "end_date": "2026-04-11",
  "status": "Planned",
  "created_at": "2026-03-28-...",
  "updated_at": "2026-03-28-..."
}

Start Sprint

Transition a sprint from Planned to Active.

POST /api/v1/sprints/{id}/start — requires auth

curl -s -X POST "$BASE_URL/api/v1/sprints/$SPRINT_ID/start" \
  -H "Authorization: Bearer $TOKEN" | jq .
{
  "id": "dd0e8400-...",
  "project_id": "990e8400-...",
  "name": "...",
  "goal": "...",
  "start_date": "...",
  "end_date": "...",
  "status": "...",
  "created_at": "2026-03-28-...",
  "updated_at": "2026-03-28-..."
}

Complete Sprint

Transition a sprint from Active to Completed.

POST /api/v1/sprints/{id}/complete — requires auth

curl -s -X POST "$BASE_URL/api/v1/sprints/$SPRINT_ID/complete" \
  -H "Authorization: Bearer $TOKEN" | jq .
{
  "id": "dd0e8400-...",
  "project_id": "990e8400-...",
  "name": "...",
  "goal": "...",
  "start_date": "...",
  "end_date": "...",
  "status": "...",
  "created_at": "2026-03-28-...",
  "updated_at": "2026-03-28-..."
}

Batch Assign Tickets to Sprint

Assign multiple tickets to a sprint at once. Requires Member role.

POST /api/v1/sprints/{id}/batch-assign — requires Member

FieldTypeRequiredDescription
ticket_idsstring[]yesArray of ticket UUIDs to assign to the sprint
curl -s -X POST "$BASE_URL/api/v1/sprints/$SPRINT_ID/batch-assign" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"ticket_ids":["'"$TICKET_ID"'"]}'
{
  "assigned": 1
}

Delete Sprint

DELETE /api/v1/sprints/{id} — requires Admin

Returns 204 No Content if the sprint has no dependents.

Returns 409 Conflict if the sprint has tickets. Use ?cascade=true to delete all dependents, or ?cascade=true&dry_run=true to preview.

ParameterTypeDefaultDescription
cascadebooleanfalseDelete all tickets in the sprint
dry_runbooleanfalseWith cascade, return counts without deleting

Cascade dry-run response (?cascade=true&dry_run=true):

{
  "sprint_id": "...",
  "dependents": {
    "tickets": 8
  }
}

409 response (no cascade, has dependents):

{
  "error": {
    "code": "CONFLICT",
    "message": "Cannot delete sprint with existing dependents",
    "details": ["tickets: 8"]
  }
}

Get Sprint Board

GET /api/v1/sprints/{id}/board — requires auth

curl -s "$BASE_URL/api/v1/sprints/$SPRINT_ID/board" \
  -H "Authorization: Bearer $TOKEN" | jq .
{
  "sprint": {
    "id": "dd0e8400-...",
    "project_id": "990e8400-...",
    "name": "...",
    "goal": "...",
    "start_date": "...",
    "end_date": "...",
    "status": "...",
    "created_at": "2026-03-28-...",
    "updated_at": "2026-03-28-..."
  },
  "columns": [
    {
      "status": "...",
      "tickets": []
    },
    {
      "status": "...",
      "tickets": []
    },
    {
      "status": "...",
      "tickets": []
    }
  ]
}

Get Sprint Burndown

GET /api/v1/sprints/{id}/burndown — requires auth

curl -s "$BASE_URL/api/v1/sprints/$SPRINT_ID/burndown" \
  -H "Authorization: Bearer $TOKEN" | jq .

Returns sprint_id and data array with daily entries containing date, total_tickets, completed_tickets, and remaining_tickets.


Workflows

Guide: Workflows and Statuses

Ticket statuses are workflow-defined — there are no hard-coded statuses. Each workflow declares its own set of statuses with categories (todo, in_progress, done, cancelled) and the allowed transitions between them. Every organization gets a “Default” workflow on creation. Projects can be assigned a workflow via PATCH /api/v1/projects/{id}.

Enforcement Modes

Workflows have an enforcement field that controls what happens when a ticket transition violates the workflow’s transition rules:

ModeBehavior
noneAny transition is allowed regardless of workflow rules (default)
warnInvalid transitions are allowed but the response includes a warning field
strictInvalid transitions are rejected with 422 Validation Error

When enforcement is strict and a transition is not allowed, the API returns:

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "transition from 'Coding' to 'Shipped' is not allowed by workflow 'Kanban'",
    "details": []
  }
}

When enforcement is warn and a transition is not allowed, the transition succeeds but the response includes a warning:

{
  "ticket": { "id": "...", "status": "Done" },
  "warning": "transition from 'Todo' to 'Done' is not allowed by workflow 'Simple Flow', but enforcement is set to warn"
}

Create Workflow

POST /api/v1/orgs/{org_id}/workflows — requires Admin

FieldTypeRequiredDescription
namestringyesWorkflow name (min 1 char)
statusesobject[]yesStatus definitions
statuses[].namestringyesStatus name
statuses[].categorystringyestodo, in_progress, done, or cancelled
transitionsobject[]yesAllowed transitions
transitions[].fromstringyesSource status name
transitions[].tostringyesTarget status name
enforcementstringnonone (default), warn, or strict
curl -s "$BASE_URL/api/v1/orgs/$ORG_ID/workflows" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Simple Flow",
    "statuses": [
      {"name": "Todo", "category": "todo"},
      {"name": "InProgress", "category": "in_progress"},
      {"name": "Done", "category": "done"}
    ],
    "transitions": [
      {"from": "Todo", "to": "InProgress"},
      {"from": "InProgress", "to": "Done"}
    ]
  }' | jq .
{
  "id": "ee0e8400-...",
  "org_id": "660e8400-...",
  "name": "Simple Flow",
  "statuses": [
    {"name": "Todo", "category": "todo"},
    {"name": "InProgress", "category": "in_progress"},
    {"name": "Done", "category": "done"}
  ],
  "transitions": [
    {"from": "Todo", "to": "InProgress"},
    {"from": "InProgress", "to": "Done"}
  ],
  "enforcement": "none",
  "created_at": "2026-03-28-...",
  "updated_at": "2026-03-28-..."
}

List Workflows

GET /api/v1/orgs/{org_id}/workflows — requires auth

ParameterTypeDefaultMaxDescription
cursorstringOpaque cursor from a previous response
limitinteger20100Number of items per page
curl -s "$BASE_URL/api/v1/orgs/$ORG_ID/workflows" \
  -H "Authorization: Bearer $TOKEN" | jq .
{
  "items": [
    {
      "id": "...",
      "org_id": "...",
      "name": "...",
      "statuses": [
        {"name": "...", "category": "..."}
      ],
      "transitions": [
        {"from": "...", "to": "..."}
      ],
      "enforcement": "...",
      "created_at": "...",
      "updated_at": "..."
    }
  ],
  "next_cursor": null,
  "has_more": false
}

Get Workflow

GET /api/v1/workflows/{id} — requires auth

curl -s "$BASE_URL/api/v1/workflows/$WORKFLOW_ID" \
  -H "Authorization: Bearer $TOKEN" | jq .
{
  "id": "ee0e8400-...",
  "org_id": "660e8400-...",
  "name": "Simple Flow",
  "statuses": [
    {"name": "Todo", "category": "todo"},
    {"name": "InProgress", "category": "in_progress"},
    {"name": "Done", "category": "done"}
  ],
  "transitions": [
    {"from": "Todo", "to": "InProgress"},
    {"from": "InProgress", "to": "Done"}
  ],
  "enforcement": "...",
  "created_at": "2026-03-28-...",
  "updated_at": "2026-03-28-..."
}

Update Workflow

PATCH /api/v1/workflows/{id} — requires Admin

FieldTypeRequiredDescription
namestringnoNew workflow name
statusesobject[]noReplacement status definitions
transitionsobject[]noReplacement transition rules
enforcementstringnonone, warn, or strict
curl -s -X PATCH "$BASE_URL/api/v1/workflows/$WORKFLOW_ID" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "Updated Flow"}' | jq .
{
  "id": "ee0e8400-...",
  "org_id": "660e8400-...",
  "name": "Updated Flow",
  "statuses": [
    {"name": "Todo", "category": "todo"},
    {"name": "InProgress", "category": "in_progress"},
    {"name": "Done", "category": "done"}
  ],
  "transitions": [
    {"from": "Todo", "to": "InProgress"},
    {"from": "InProgress", "to": "Done"}
  ],
  "enforcement": "...",
  "created_at": "2026-03-28-...",
  "updated_at": "2026-03-28-..."
}

Delete Workflow

DELETE /api/v1/workflows/{id} — requires Admin

Returns 204 No Content if the workflow has no dependents.

Returns 409 Conflict if any projects are using this workflow:

{
  "error": {
    "code": "CONFLICT",
    "message": "Cannot delete workflow with existing dependents",
    "details": ["projects: 3"]
  }
}

Note: Unlike other DELETE endpoints, workflow deletion does not support cascade or dry_run parameters. Unassign the workflow from all projects before deleting.

curl -s -X DELETE "$BASE_URL/api/v1/workflows/$WORKFLOW_ID" \
  -H "Authorization: Bearer $TOKEN"

Returns 204 No Content on success.

Transition Ticket

Move a ticket to a new status. If a workflow is assigned to the project, the transition is validated against the workflow’s rules and enforcement mode.

POST /api/v1/tickets/{id}/transition — requires auth

FieldTypeRequiredDescription
to_statusstringyesTarget status name
curl -s -X POST "$BASE_URL/api/v1/tickets/$TICKET_ID/transition" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"to_status": "InProgress"}' | jq .

Returns the updated TicketResponse.

{
  "id": "aa0e8400-...",
  "project_id": "990e8400-...",
  "title": "Fix login bug",
  "status": "InProgress",
  "priority": "High",
  "created_at": "2026-03-28-...",
  "updated_at": "2026-03-28-..."
}

Get Available Transitions

List the statuses a ticket can transition to from its current status.

GET /api/v1/tickets/{id}/transitions — requires auth

curl -s "$BASE_URL/api/v1/tickets/$TICKET_ID/transitions" \
  -H "Authorization: Bearer $TOKEN" | jq .
{
  "current_status": "...",
  "available": [
    {
      "name": "...",
      "category": "..."
    }
  ]
}

Batch Transition Tickets

Transition multiple tickets to the same target status in one call. Each ticket is validated individually against its project’s workflow. Returns per-ticket results so callers can see which succeeded and which failed. Useful for sprint completion (move all tickets to Done).

POST /api/v1/tickets/batch-transition — requires auth

FieldTypeRequiredDescription
ticket_idsstring[]yesArray of ticket UUIDs to transition
to_statusstringyesTarget status name
curl -s -X POST "$BASE_URL/api/v1/tickets/batch-transition" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"ticket_ids":["'"$TICKET_ID"'"],"to_status":"Done"}'
{
  "succeeded": [
    {
      "id": "...",
      "status": "Done"
    }
  ],
  "failed": []
}

When a transition fails for a ticket (e.g., invalid workflow transition in strict mode, or ticket not found), it appears in the failed array with a reason:

{
  "succeeded": [],
  "failed": [
    {
      "id": "...",
      "reason": "not found: ticket ..."
    }
  ]
}

Time Entries

Guide: Time Tracking and Finance

Create Time Entry

POST /api/v1/time-entries — requires auth

FieldTypeRequiredDescription
user_idstringyesUser UUID
ticket_idstringyesTicket UUID
project_idstringyesProject UUID
datestringyesISO 8601 date
duration_minutesintegeryesDuration in minutes
descriptionstringnoEntry description
activity_typestringyesCoding, CodeReview, BugFixing, Testing, Documentation, Design, Meeting, Planning
curl -s "$BASE_URL/api/v1/time-entries" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d "{
    \"user_id\": \"$USER_ID\",
    \"ticket_id\": \"$TICKET_ID\",
    \"project_id\": \"$PROJECT_ID\",
    \"date\": \"2026-03-28\",
    \"duration_minutes\": 120,
    \"description\": \"Investigated SSO bug\",
    \"activity_type\": \"Coding\"
  }" | jq .
{
  "id": "ff0e8400-...",
  "user_id": "550e8400-...",
  "ticket_id": "aa0e8400-...",
  "project_id": "990e8400-...",
  "date": "...",
  "duration_minutes": 120,
  "description": "...",
  "activity_type": "...",
  "status": "...",
  "approved_by": null,
  "approved_at": null,
  "created_at": "2026-03-28-...",
  "updated_at": "2026-03-28-..."
}

Get Time Entry

GET /api/v1/time-entries/{id} — requires auth

curl -s "$BASE_URL/api/v1/time-entries/$TIME_ENTRY_ID" \
  -H "Authorization: Bearer $TOKEN" | jq .
{
  "id": "ff0e8400-...",
  "user_id": "550e8400-...",
  "ticket_id": "aa0e8400-...",
  "project_id": "990e8400-...",
  "date": "...",
  "duration_minutes": 120,
  "description": "...",
  "activity_type": "...",
  "status": "...",
  "approved_by": null,
  "approved_at": null,
  "created_at": "2026-03-28-...",
  "updated_at": "2026-03-28-..."
}

List Time Entries by Ticket

GET /api/v1/tickets/{ticket_id}/time-entries — requires auth

Query ParamTypeRequiredDescription
user_idstringnoFilter by user
project_idstringnoFilter by project
datestringnoFilter by date
cursorstringnoPagination cursor
limitintegernoPage size (default 20, max 100)
curl -s "$BASE_URL/api/v1/tickets/$TICKET_ID/time-entries" \
  -H "Authorization: Bearer $TOKEN" | jq .

Returns a paginated list of TimeEntryResponse objects.

List Time Entries by User

GET /api/v1/users/{user_id}/time-entries — requires auth

Query ParamTypeRequiredDescription
project_idstringnoFilter by project
datestringnoFilter by date
cursorstringnoPagination cursor
limitintegernoPage size (default 20, max 100)
curl -s "$BASE_URL/api/v1/users/$USER_ID/time-entries" \
  -H "Authorization: Bearer $TOKEN" | jq .

Returns a paginated list of TimeEntryResponse objects.

Update Time Entry

PATCH /api/v1/time-entries/{id} — requires auth

FieldTypeRequiredDescription
datestringnoNew date
duration_minutesintegernoNew duration
descriptionstring | nullnoNew description or null
activity_typestringnoNew activity type
curl -s -X PATCH "$BASE_URL/api/v1/time-entries/$TIME_ENTRY_ID" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"duration_minutes": 180}' | jq .

Returns the updated TimeEntryResponse.

Delete Time Entry

DELETE /api/v1/time-entries/{id} — requires auth

curl -s -X DELETE "$BASE_URL/api/v1/time-entries/$TIME_ENTRY_ID" \
  -H "Authorization: Bearer $TOKEN"

Returns 204 No Content on success.

Submit Time Entry

POST /api/v1/time-entries/{id}/submit — requires auth

Transitions a time entry from Draft to Submitted status.

curl -s -X POST "$BASE_URL/api/v1/time-entries/$TIME_ENTRY_ID/submit" \
  -H "Authorization: Bearer $TOKEN" | jq .

Returns the updated TimeEntryResponse with "status": "Submitted".

Approve Time Entry

POST /api/v1/time-entries/{id}/approve — requires auth

Transitions a time entry from Submitted to Approved status.

curl -s -X POST "$BASE_URL/api/v1/time-entries/$TIME_ENTRY_ID/approve" \
  -H "Authorization: Bearer $TOKEN" | jq .

Returns the updated TimeEntryResponse with "status": "Approved", approved_by, and approved_at populated.

List Submitted Time Entries

GET /api/v1/time-entries/submitted — requires auth

Returns all time entries with Submitted status, typically used by managers for approval workflows.

curl -s "$BASE_URL/api/v1/time-entries/submitted" \
  -H "Authorization: Bearer $TOKEN" | jq .

Returns an array of TimeEntryResponse objects.


Labor Rates

Guide: Time Tracking and Finance

Labor rates define the loaded cost rate per user per org, used for capitalization reporting. All labor rate endpoints require Admin or Owner role.

Create Labor Rate

POST /api/v1/labor-rates — requires auth (Admin/Owner)

FieldTypeRequiredDescription
user_idstringyesUser UUID
org_idstringyesOrganization UUID
loaded_rate_centsintegeryesLoaded rate in cents per hour
effective_datestringyesISO 8601 date when rate takes effect
curl -s "$BASE_URL/api/v1/labor-rates" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d "{
    \"user_id\": \"$USER_ID\",
    \"org_id\": \"$ORG_ID\",
    \"loaded_rate_cents\": 15000,
    \"effective_date\": \"2026-01-01\"
  }" | jq .
{
  "id": "bb0e8400-...",
  "user_id": "550e8400-...",
  "org_id": "660e8400-...",
  "loaded_rate_cents": 15000,
  "effective_date": "2026-01-01",
  "created_at": "2026-03-28-...",
  "updated_at": "2026-03-28-..."
}

Get Labor Rate

GET /api/v1/labor-rates/{id} — requires auth (Admin/Owner)

curl -s "$BASE_URL/api/v1/labor-rates/$LABOR_RATE_ID" \
  -H "Authorization: Bearer $TOKEN" | jq .

Returns a UserLaborRateResponse.

Get Current Labor Rate

GET /api/v1/users/{user_id}/orgs/{org_id}/labor-rates/current — requires auth (Admin/Owner)

Returns the most recent labor rate by effective date for the given user and org.

curl -s "$BASE_URL/api/v1/users/$USER_ID/orgs/$ORG_ID/labor-rates/current" \
  -H "Authorization: Bearer $TOKEN" | jq .

Returns a UserLaborRateResponse.

List Labor Rates

GET /api/v1/users/{user_id}/orgs/{org_id}/labor-rates — requires auth (Admin/Owner)

Query ParamTypeRequiredDescription
cursorstringnoPagination cursor
limitintegernoPage size (default 50, max 100)
curl -s "$BASE_URL/api/v1/users/$USER_ID/orgs/$ORG_ID/labor-rates" \
  -H "Authorization: Bearer $TOKEN" | jq .

Returns a paginated list of UserLaborRateResponse objects.


Entity Tags

Guide: Labels, Tags, and Organization

Tags are arbitrary key-value pairs attached to entities (projects, tickets, users, teams, time entries). They enable flexible metadata and filtering.

Set Tags

PUT /api/v1/orgs/{org_id}/{entity_type}/{entity_id}/tags — requires auth

Sets or upserts tags on an entity. If a tag key already exists, its value is updated.

Path ParamTypeRequiredDescription
org_idstringyesOrganization UUID
entity_typestringyesOne of: project, ticket, user, team, time_entry
entity_idstringyesEntity UUID
Body FieldTypeRequiredDescription
tagsarrayyesArray of {"key": "...", "value": "..."} objects
curl -s -X PUT "$BASE_URL/api/v1/orgs/$ORG_ID/project/$PROJECT_ID/tags" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "tags": [
      {"key": "department", "value": "engineering"},
      {"key": "environment", "value": "production"}
    ]
  }' | jq .
[
  {
    "id": "...",
    "org_id": "...",
    "entity_type": "project",
    "entity_id": "...",
    "key": "department",
    "value": "engineering",
    "created_at": "...",
    "updated_at": "..."
  },
  {
    "id": "...",
    "org_id": "...",
    "entity_type": "project",
    "entity_id": "...",
    "key": "environment",
    "value": "production",
    "created_at": "...",
    "updated_at": "..."
  }
]

Get Tags

GET /api/v1/orgs/{org_id}/{entity_type}/{entity_id}/tags — requires auth

Returns all tags for an entity.

curl -s "$BASE_URL/api/v1/orgs/$ORG_ID/project/$PROJECT_ID/tags" \
  -H "Authorization: Bearer $TOKEN" | jq .
[
  {
    "id": "...",
    "org_id": "...",
    "entity_type": "project",
    "entity_id": "...",
    "key": "department",
    "value": "engineering",
    "created_at": "...",
    "updated_at": "..."
  }
]

Delete Tag

DELETE /api/v1/orgs/{org_id}/{entity_type}/{entity_id}/tags/{key} — requires auth

Removes a single tag by key from an entity.

curl -s -X DELETE "$BASE_URL/api/v1/orgs/$ORG_ID/project/$PROJECT_ID/tags/environment" \
  -H "Authorization: Bearer $TOKEN"

Returns 204 No Content.

Search by Tag

GET /api/v1/orgs/{org_id}/tags/search — requires auth

Finds all entities with a given tag key-value pair. Returns paginated results.

Query ParamTypeRequiredDescription
keystringyesTag key to search for
valuestringyesTag value to match
cursorstringnoPagination cursor
limitintegernoPage size (default 20)
curl -s "$BASE_URL/api/v1/orgs/$ORG_ID/tags/search?key=department&value=engineering" \
  -H "Authorization: Bearer $TOKEN" | jq .
{
  "items": [
    {
      "id": "...",
      "org_id": "...",
      "entity_type": "project",
      "entity_id": "...",
      "key": "department",
      "value": "engineering",
      "created_at": "...",
      "updated_at": "..."
    }
  ],
  "next_cursor": null,
  "has_more": false
}

Capitalization Reports

Guide: Time Tracking and Finance

Generate capitalization reports for software cost accounting (ASC 350-40 / IAS 38). Reports aggregate approved time entries with labor rates by project and activity type.

Get Capitalization Report

GET /api/v1/reports/capitalization — requires auth

Query ParamTypeRequiredDescription
periodstringyesReport period in YYYY-MM format
group_bystringnoGroup results by team or user
include_usersbooleannoInclude per-user breakdown in project entries
include_budgetbooleannoInclude budget/ROI fields on each project
team_idstringnoFilter to a specific team UUID
user_idstringnoFilter to a specific user UUID
cost_center_idstringnoFilter to a specific cost center
activity_typestringnoFilter to a specific activity type (e.g. Coding)
tagstringnoFilter by tag. Comma-separated key:value pairs. Multiple tags combine with AND logic

Default (flat) response:

curl -s "$BASE_URL/api/v1/reports/capitalization?period=2026-03" \
  -H "Authorization: Bearer $TOKEN" | jq .

Returns {"period": "2026-03", "projects": [...]} where each project includes project_id, project_key, project_name, capitalization_type, development_phase, cost_center_id, total_hours, total_amount_cents, and a breakdown array of {activity_type, hours, amount_cents}.

With budget fields — add include_budget=true to include budget_cents, budget_period, spent_cents, budget_remaining_cents, and budget_utilization_pct on each project entry.

Grouped by team — add group_by=team to return {"period": "...", "teams": [{team_id, team_name, total_hours, total_amount_cents, projects}]}.

Grouped by user — add group_by=user to return {"period": "...", "users": [{user_id, display_name, total_hours, total_amount_cents, projects}]}.

Filter by tag — add tag=department:engineering to only include projects with that tag.

Export Capitalization Report (CSV)

GET /api/v1/reports/capitalization/export — requires auth

Supports all the same query parameters as the JSON endpoint above.

Query ParamTypeRequiredDescription
periodstringyesReport period in YYYY-MM format
formatstringnoExport format (default: csv)
team_idstringnoFilter to a specific team UUID
user_idstringnoFilter to a specific user UUID
cost_center_idstringnoFilter to a specific cost center
activity_typestringnoFilter to a specific activity type
tagstringnoFilter by tag (comma-separated key:value pairs)
curl -s "$BASE_URL/api/v1/reports/capitalization/export?period=2026-03" \
  -H "Authorization: Bearer $TOKEN" -o report.csv

Returns a CSV file with header: Period,Project,ProjectKey,Employee,Department,CostCenter,Hours,ActivityType,Phase,CapExOpEx,LoadedRate,Amount,Team,Tags,BudgetCents,SpentCents,Utilization


Data Export & Import

Export complete project data as portable JSON or SQLite, or import data from an Alloy export to restore or migrate between instances. Relationships use human-readable keys (emails, project keys, label names) instead of UUIDs, making exports portable across Alloy instances.

Export Data

GET /api/v1/export — requires auth

Query ParamTypeRequiredDescription
org_idstringyesOrganization UUID
projectstringnoFilter to a single project by key (e.g. PROJ)
formatstringnoExport format: json (default) or sqlite
curl -s "$BASE_URL/api/v1/export?org_id=$ORG_ID" \
  -H "Authorization: Bearer $TOKEN"
{
  "version": 1,
  "source": "alloy",
  "exported_at": "...",
  "projects": "...",
  "labels": "...",
  "workflows": "...",
  "users": "..."
}

Filter by project key — append &project=KEY to export a single project:

GET /api/v1/export?org_id=<ORG_ID>&project=DEMO

Export as SQLite — append &format=sqlite to download a self-contained SQLite database:

curl -s "$BASE_URL/api/v1/export?org_id=$ORG_ID&format=sqlite" \
  -H "Authorization: Bearer $TOKEN" -o alloy-export.db

The SQLite file contains tables: export_meta, users, labels, workflows, projects, project_members, sprints, tickets, ticket_labels, comments, time_entries. It can be opened and queried with any SQLite client.

Import Data

POST /api/v1/import — requires auth

Accepts an AlloyExport JSON body (as produced by the export endpoint) and creates all entities in the authenticated user’s organization, preserving relationships. Users and workflows are resolved by email/name to avoid duplicates. Returns a summary of created entities.

FieldTypeRequiredDescription
versionintegeryesExport format version (must be 1)
sourcestringyesSource identifier (e.g. "alloy")
exported_atstringyesISO 8601 timestamp of when the export was created
projectsarrayyesArray of project objects
labelsarrayyesArray of label objects
workflowsarrayyesArray of workflow objects
usersarrayyesArray of user objects
curl -s -X POST "$BASE_URL/api/v1/import" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"version":1,"source":"alloy","exported_at":"2026-01-01T00:00:00Z","projects":[],"labels":[],"workflows":[],"users":[]}'
{
  "status": "ok",
  "summary": {
    "users_created": 0,
    "workflows_created": 0,
    "labels_created": 0,
    "projects_created": 0,
    "sprints_created": 0,
    "tickets_created": 0,
    "comments_created": 0,
    "time_entries_created": 0
  }
}

With a file — for larger exports, use a file reference:

curl -s -X POST "$BASE_URL/api/v1/import" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d @alloy-export.json

Round-trip example — export from one instance and import into another:

curl -s "$SOURCE_URL/api/v1/export?org_id=$ORG_ID" \
  -H "Authorization: Bearer $SOURCE_TOKEN" \
  | curl -s -X POST "$TARGET_URL/api/v1/import" \
    -H "Authorization: Bearer $TARGET_TOKEN" \
    -H "Content-Type: application/json" \
    -d @-

Validation errors — returns 422 if the export version is unsupported:

curl -s -X POST "$BASE_URL/api/v1/import" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"version":99,"source":"test","exported_at":"2026-01-01T00:00:00Z","projects":[],"labels":[],"workflows":[],"users":[]}'

# → 422 {"error":"Validation error","details":["unsupported export version: 99. Expected: 1"]}

Webhooks

Webhooks deliver real-time event notifications to external URLs via HTTP POST. Events are signed with HMAC-SHA256 using the webhook secret.

Create Webhook

POST /api/v1/orgs/{org_id}/webhooks — requires auth

FieldTypeRequiredDescription
urlstringyesDestination URL (must be valid URL)
event_typesarrayyesEvents to subscribe to

Supported event types: ticket.created, ticket.updated, ticket.status_changed, comment.created, sprint.started, sprint.completed

curl -s "$BASE_URL/api/v1/orgs/$ORG_ID/webhooks" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/hooks/alloy",
    "event_types": ["ticket.created", "ticket.updated"]
  }' | jq .
{
  "id": "cc0e8400-...",
  "org_id": "660e8400-...",
  "url": "...",
  "secret": "...",
  "event_types": [
    "...",
    "..."
  ],
  "active": true,
  "created_at": "2026-03-28-...",
  "updated_at": "2026-03-28-..."
}

Note: The secret field is only returned on creation. Store it securely — it is used to verify webhook signatures via the X-Alloy-Signature header.

List Webhooks

GET /api/v1/orgs/{org_id}/webhooks — requires auth

Query ParamTypeRequiredDescription
cursorstringnoPagination cursor
limitintegernoPage size (default 20, max 100)
curl -s "$BASE_URL/api/v1/orgs/$ORG_ID/webhooks" \
  -H "Authorization: Bearer $TOKEN" | jq .

Returns a paginated list of WebhookResponse objects (without the secret field).

Get Webhook

GET /api/v1/webhooks/{id} — requires auth

curl -s "$BASE_URL/api/v1/webhooks/$WEBHOOK_ID" \
  -H "Authorization: Bearer $TOKEN" | jq .
{
  "id": "cc0e8400-...",
  "org_id": "660e8400-...",
  "url": "https://example.com/hooks/alloy",
  "event_types": ["ticket.created", "ticket.updated"],
  "active": true,
  "created_at": "2026-03-28-...",
  "updated_at": "2026-03-28-..."
}

Delete Webhook

DELETE /api/v1/webhooks/{id} — requires auth

curl -s -X DELETE "$BASE_URL/api/v1/webhooks/$WEBHOOK_ID" \
  -H "Authorization: Bearer $TOKEN"

Returns 204 No Content on success.

List Webhook Deliveries

GET /api/v1/webhooks/{webhook_id}/deliveries — requires auth

Query ParamTypeRequiredDescription
cursorstringnoPagination cursor
limitintegernoPage size (default 20, max 100)
curl -s "$BASE_URL/api/v1/webhooks/$WEBHOOK_ID/deliveries" \
  -H "Authorization: Bearer $TOKEN" | jq .

SSO Authentication

Single sign-on via OpenID Connect (OIDC) with PKCE. Requires the org to have an SSO provider configured.

SSO Discovery

GET /api/v1/auth/sso/discover — no auth required

Query ParamTypeRequiredDescription
org_idstringyesOrganization UUID

Returns the IdP authorization URL and state parameter to begin the SSO flow.

curl -s "$BASE_URL/api/v1/auth/sso/discover?org_id=$ORG_ID" | jq .

Redirect the user’s browser to authorization_url to begin login.

SSO Callback

GET /api/v1/auth/sso/callback — no auth required

Query ParamTypeRequiredDescription
codestringyesAuthorization code from IdP
statestringyesState parameter from discovery
curl -s "$BASE_URL/api/v1/auth/sso/callback?code=$AUTH_CODE&state=$STATE" | jq .

SCIM Provisioning

SCIM 2.0 endpoints for automated user and group provisioning from identity providers. All SCIM endpoints require a Bearer token and are mounted at /scim/v2/.

Configuration: SCIM provisioning requires an SSO provider to be configured for the org. The SCIM bearer token is generated during SSO setup.

Service Provider Config

GET /scim/v2/ServiceProviderConfig — requires SCIM bearer token

curl -s "$BASE_URL/scim/v2/ServiceProviderConfig" \
  -H "Authorization: Bearer $SCIM_TOKEN" | jq .

Returns the SCIM service provider configuration describing supported features.

List Users (SCIM)

GET /scim/v2/Users — requires SCIM bearer token

curl -s "$BASE_URL/scim/v2/Users" \
  -H "Authorization: Bearer $SCIM_TOKEN" | jq .

Create User (SCIM)

POST /scim/v2/Users — requires SCIM bearer token

FieldTypeRequiredDescription
userNamestringyesEmail address
displayNamestringnoDisplay name
curl -s "$BASE_URL/scim/v2/Users" \
  -H "Authorization: Bearer $SCIM_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"userName": "new@example.com", "displayName": "New User"}' | jq .

Returns the created ScimUser with 201 Created.

Get User (SCIM)

GET /scim/v2/Users/{id} — requires SCIM bearer token

curl -s "$BASE_URL/scim/v2/Users/$USER_ID" \
  -H "Authorization: Bearer $SCIM_TOKEN" | jq .

Returns a ScimUser object.

Update User (SCIM)

PATCH /scim/v2/Users/{id} — requires SCIM bearer token

Uses SCIM PATCH operations. Common operation: deactivate a user.

curl -s -X PATCH "$BASE_URL/scim/v2/Users/$USER_ID" \
  -H "Authorization: Bearer $SCIM_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "Operations": [{"op": "replace", "path": "active", "value": false}]
  }' | jq .

Returns the updated ScimUser.

List Groups (SCIM)

GET /scim/v2/Groups — requires SCIM bearer token

curl -s "$BASE_URL/scim/v2/Groups" \
  -H "Authorization: Bearer $SCIM_TOKEN" | jq .

Create Group (SCIM)

POST /scim/v2/Groups — requires SCIM bearer token

FieldTypeRequiredDescription
displayNamestringyesGroup name
membersarraynoArray of {"value": "<user_id>"}
curl -s "$BASE_URL/scim/v2/Groups" \
  -H "Authorization: Bearer $SCIM_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"displayName": "Design", "members": [{"value": "'$USER_ID'"}]}' | jq .

Returns the created ScimGroup with 201 Created.

Get Group (SCIM)

GET /scim/v2/Groups/{id} — requires SCIM bearer token

curl -s "$BASE_URL/scim/v2/Groups/$GROUP_ID" \
  -H "Authorization: Bearer $SCIM_TOKEN" | jq .

Returns a ScimGroup object.

Update Group (SCIM)

PATCH /scim/v2/Groups/{id} — requires SCIM bearer token

curl -s -X PATCH "$BASE_URL/scim/v2/Groups/$GROUP_ID" \
  -H "Authorization: Bearer $SCIM_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "Operations": [{"op": "replace", "path": "displayName", "value": "Platform Engineering"}]
  }' | jq .

Returns the updated ScimGroup.


GitHub Integration

Receives GitHub webhook events to automatically transition ticket statuses based on pull request activity.

Configuration: Requires ALLOY_GITHUB_WEBHOOK_SECRET environment variable. Configure a GitHub webhook pointing to this endpoint with the pull_request event.

Receive GitHub Webhook

POST /api/v1/integrations/github/webhook — no auth required (signature verified)

HeaderRequiredDescription
X-Hub-Signature-256yesHMAC-SHA256 signature of the payload
X-GitHub-EventyesEvent type (e.g., pull_request)

The handler parses ticket references (e.g., PROJ-123) from PR titles and branch names, then transitions tickets:

  • PR opened/reopened → In Review
  • PR closed + merged → Done
  • PR closed (not merged) → In Progress
{
  "status": "processed",
  "ticket_refs_found": ["ALLOY-42", "ALLOY-43"],
  "transitions": [
    {"ticket_ref": "ALLOY-42", "old_status": "In Progress", "new_status": "In Review"},
    {"ticket_ref": "ALLOY-43", "old_status": "In Progress", "new_status": "In Review"}
  ]
}

Slack Integration

Slack integration provides slash commands for ticket management and real-time notifications.

Configuration: Requires ALLOY_SLACK_SIGNING_SECRET, ALLOY_SLACK_BOT_TOKEN, and optionally ALLOY_SLACK_DEFAULT_CHANNEL environment variables.

Receive Slack Command

POST /api/v1/integrations/slack/commands — no auth required (signature verified)

HeaderRequiredDescription
X-Slack-Request-TimestampyesRequest timestamp
X-Slack-SignatureyesHMAC-SHA256 signature (v0=...)

Request body is application/x-www-form-urlencoded with Slack command fields.

Supported commands:

  • /ticket create PROJ Title here — creates a new ticket
  • /ticket PROJ-42 — looks up a ticket by reference
{
  "response_type": "in_channel",
  "blocks": [
    {
      "type": "section",
      "text": {"type": "mrkdwn", "text": "*ALLOY-42: Fix login bug*"},
      "fields": [
        {"type": "mrkdwn", "text": "*Status:* In Progress"},
        {"type": "mrkdwn", "text": "*Priority:* High"}
      ]
    }
  ]
}

Dispatch Slack Notification

POST /api/v1/integrations/slack/notify — requires auth

FieldTypeRequiredDescription
notification_typestringyesassignment, mention, status_change, comment_sync
ticket_idstringyesTicket UUID
channel_idstringnoSlack channel ID
user_slack_idstringnoSlack user ID for DM
messagestringnoCustom message text
old_statusstringnoPrevious status (for status_change)
new_statusstringnoNew status (for status_change)
actor_namestringnoWho triggered the action
thread_tsstringnoSlack thread timestamp
curl -s "$BASE_URL/api/v1/integrations/slack/notify" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d "{
    \"notification_type\": \"status_change\",
    \"ticket_id\": \"$TICKET_ID\",
    \"channel_id\": \"C0123456789\",
    \"old_status\": \"In Progress\",
    \"new_status\": \"Done\",
    \"actor_name\": \"Jane Smith\"
  }" | jq .
{
  "status": "queued",
  "notification_type": "status_change"
}

Receive Slack Event

POST /api/v1/integrations/slack/events — no auth required (signature verified)

Handles Slack Events API payloads, including URL verification challenges and message events for comment syncing.

# URL verification (Slack sends this during setup)
curl -s "$BASE_URL/api/v1/integrations/slack/events" \
  -H "Content-Type: application/json" \
  -d '{"type": "url_verification", "challenge": "abc123"}' | jq .
{
  "challenge": "abc123",
  "status": "ok"
}

Create Thread Mapping

POST /api/v1/integrations/slack/thread-mappings — requires auth

Links a Slack thread to an Alloy ticket for bi-directional comment syncing.

FieldTypeRequiredDescription
ticket_idstringyesTicket UUID
channel_idstringyesSlack channel ID
thread_tsstringyesSlack thread timestamp
curl -s "$BASE_URL/api/v1/integrations/slack/thread-mappings" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d "{
    \"ticket_id\": \"$TICKET_ID\",
    \"channel_id\": \"C0123456789\",
    \"thread_ts\": \"1679012345.678900\"
  }" | jq .
{
  "id": "ee0e8400-...",
  "ticket_id": "aa0e8400-...",
  "channel_id": "C0123456789",
  "thread_ts": "1679012345.678900",
  "created_at": "2026-03-28-..."
}

Attachments

Request Upload URL

Get a presigned S3 URL for uploading a file.

POST /api/v1/tickets/{ticket_id}/attachments/presign — requires auth

FieldTypeRequiredDescription
filenamestringyesFile name
content_typestringyesMIME type
size_bytesintegeryesFile size in bytes
curl -s "$BASE_URL/api/v1/tickets/$TICKET_ID/attachments/presign" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"filename": "screenshot.png", "content_type": "image/png", "size_bytes": 102400}' | jq .
{
  "upload_url": "https://s3.amazonaws.com/...",
  "s3_key": "..."
}

Confirm Upload

After uploading the file to the presigned URL, confirm the attachment.

POST /api/v1/tickets/{ticket_id}/attachments/confirm — requires auth

FieldTypeRequiredDescription
filenamestringyesFile name
content_typestringyesMIME type
size_bytesintegeryesFile size
s3_keystringyesS3 key from presign response
uploaded_bystringyesUser UUID
curl -s "$BASE_URL/api/v1/tickets/$TICKET_ID/attachments/confirm" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d "{
    \"filename\": \"screenshot.png\",
    \"content_type\": \"image/png\",
    \"size_bytes\": 102400,
    \"s3_key\": \"attachments/abc123/screenshot.png\",
    \"uploaded_by\": \"$USER_ID\"
  }" | jq .
{
  "id": "110e8400-...",
  "ticket_id": "aa0e8400-...",
  "filename": "screenshot.png",
  "content_type": "image/png",
  "size_bytes": 102400,
  "s3_key": "attachments/abc123/screenshot.png",
  "uploaded_by": "550e8400-...",
  "created_at": "2026-03-28-...",
  "download_url": null
}

List Attachments

GET /api/v1/tickets/{ticket_id}/attachments — requires auth

curl -s "$BASE_URL/api/v1/tickets/$TICKET_ID/attachments" \
  -H "Authorization: Bearer $TOKEN" | jq .

Returns a paginated list of AttachmentResponse objects.


Health

Health Check

GET /health — no auth required

curl -s "$BASE_URL/health" | jq .
{
  "status": "ok",
  "service": "alloy",
  "version": "0.1.0",
  "database": "sqlite",
  "db_healthy": true,
  "migration_version": "...",
  "uptime_seconds": "..."
}

Version

Version Info

GET /api/v1/version — no auth required

curl -s "$BASE_URL/api/v1/version" | jq .
{
  "version": "0.1.0",
  "service": "alloy",
  "profile": "..."
}