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
Every organization member has one of five roles, listed from most to least privileged:
| Role | Description |
|---|---|
| Owner | Full control. Created automatically for the org creator. |
| Admin | Manage workflows, teams, invites, delete resources. |
| Member | Create and update projects, tickets, sprints, time entries. |
| Reporter | Create tickets and comments. Limited to assigned projects. |
| Viewer | Read-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.
| Operation | Minimum Role |
|---|---|
| Orgs | |
| Create / update org | Owner |
| Invites | |
| Create / revoke invite | Admin |
| List invites | Any authenticated |
| Workflows | |
| Create / update / delete workflow | Admin |
| Teams | |
| Delete team | Admin |
| Projects | |
| Create / update project | Member |
| Delete project | Admin |
| Project Members | |
| Add / remove project member | Member |
| List project members | Any authenticated |
| Tickets | |
| Create ticket | Reporter (must be assigned to the project) |
| Update / transition ticket | Member |
| Delete ticket | Admin |
| Comments | |
| Create comment | Reporter |
| Update / delete comment | Author or Admin |
| Sprints | |
| Create / update / start / complete sprint | Member |
| Delete sprint | Admin |
| Time Entries | |
| Create / update / delete / submit time entry | Member |
| Approve time entry | Admin |
| Labor Rates | |
| All labor rate operations | Admin |
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": []
}
}
| Status | Meaning |
|---|---|
| 401 | Missing or invalid token |
| 403 | Insufficient permissions (see Roles and Permissions) |
| 404 | Resource not found |
| 409 | Conflict — resource has dependents or duplicate key (see Delete Guards) |
| 422 | Validation error (details array lists each field) |
| 500 | Internal 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:
| Parameter | Type | Default | Description |
|---|---|---|---|
cascade | boolean | false | Delete all dependents recursively |
dry_run | boolean | false | With 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
}
| Parameter | Type | Default | Max | Description |
|---|---|---|---|---|
cursor | string | — | — | Opaque cursor from a previous response |
limit | integer | 20 | 100 | Number of items per page |
Auth
Register
Create a new user account.
POST /api/v1/auth/register — no auth required
| Field | Type | Required | Description |
|---|---|---|---|
email | string | yes | Valid email address |
password | string | yes | Minimum 8 characters |
display_name | string | yes | 1–255 characters |
invite_code | string | no | Required 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
| Field | Type | Required | Description |
|---|---|---|---|
email | string | yes | Email address |
password | string | yes | Password |
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
| Field | Type | Required | Description |
|---|---|---|---|
refresh_token | string | yes | Refresh 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
| Field | Type | Required | Description |
|---|---|---|---|
refresh_token | string | yes | Token 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
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Key name, 1–255 characters |
scopes | string[] | no | ["read", "write"] (default) or ["admin"] |
project_ids | string[] | no | Restrict 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
| Field | Type | Required | Description |
|---|---|---|---|
email | string | yes | Admin email |
password | string | yes | Minimum 8 characters |
org_name | string | yes | Organization name, 1–255 characters |
org_slug | string | yes | Organization 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
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | 1–255 characters |
slug | string | yes | 1–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
| Field | Type | Required | Description |
|---|---|---|---|
user_id | string | yes | UUID of user to add |
role | string | no | owner, 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
| Field | Type | Required | Description |
|---|---|---|---|
email | string | no | Email to invite |
role | string | no | Role to assign (default: member) |
created_by | string | no | (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
| Field | Type | Required | Description |
|---|---|---|---|
org_id | string | yes | Organization UUID |
key | string | yes | Short project key, 1–10 chars (e.g. PROJ) |
name | string | yes | 1–255 characters |
description | string | no | Project description |
team_id | string | no | Team UUID |
budget_cents | integer | no | Budget amount in cents |
budget_period | string | no | Budget 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 Param | Type | Required | Description |
|---|---|---|---|
org_id | string | yes | Organization UUID |
cursor | string | no | Pagination cursor |
limit | integer | no | Page 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
| Parameter | Type | Required | Description |
|---|---|---|---|
org_id | string (query) | no | Organization 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
| Field | Type | Required | Description |
|---|---|---|---|
name | string | no | New name |
description | string | null | no | New description or null to clear |
team_id | string | null | no | New team or null to unset |
capitalization_type | string | null | no | Capitalization type |
development_phase | string | null | no | Development phase |
cost_center_id | string | null | no | Cost center ID |
amortization_months | integer | null | no | Amortization months |
budget_cents | integer | null | no | Budget amount in cents, or null to clear |
budget_period | string | null | no | Monthly, 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.
| Parameter | Type | Default | Description |
|---|---|---|---|
cascade | boolean | false | Delete all tickets, sprints, and comments |
dry_run | boolean | false | With 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
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
| Field | Type | Required | Description |
|---|---|---|---|
user_id | string | yes | User 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
| Field | Type | Required | Description |
|---|---|---|---|
title | string | yes | 1–500 characters |
description | string | no | Ticket description |
status | string | no | Backlog (default), Todo, InProgress, InReview, Done, Cancelled |
priority | string | no | None (default), Low, Medium, High, Critical |
assignee_id | string | no | User UUID |
reporter_id | string | no | (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 Param | Type | Required | Description |
|---|---|---|---|
status | string | no | Filter by status |
priority | string | no | Filter by priority |
assignee_id | string | no | Filter by assignee |
cursor | string | no | Pagination cursor |
limit | integer | no | Page 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 Param | Type | Required | Description |
|---|---|---|---|
ref | string | yes | UUID, KEY-NUMBER, or bare number |
project_id | string | no | Required when using bare number |
org_id | string | no | Organization 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
| Field | Type | Required | Description |
|---|---|---|---|
title | string | no | New title |
description | string | null | no | New description or null |
status | string | no | New status |
priority | string | no | New priority |
assignee_id | string | null | no | New 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.
| Parameter | Type | Default | Description |
|---|---|---|---|
cascade | boolean | false | Delete all comments |
dry_run | boolean | false | With 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
| Field | Type | Required | Description |
|---|---|---|---|
ticket_ids | string[] | yes | Array of ticket UUIDs to update |
title | string | no | New title for all tickets |
description | string|null | no | New description (null to clear) |
status | string | no | New status |
priority | string | no | New priority |
assignee_id | string|null | no | Assignee UUID (null to clear) |
sprint_id | string|null | no | Sprint 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
| Field | Type | Required | Description |
|---|---|---|---|
ticket_ids | string[] | yes | Array of ticket UUIDs to delete |
cascade | boolean | no | If true, delete dependents (comments) too. Default: false |
dry_run | boolean | no | If 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
| Field | Type | Required | Description |
|---|---|---|---|
to_status | string | yes | Target 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
| Field | Type | Required | Description |
|---|---|---|---|
body | string | yes | Comment text (min 1 char) |
author_id | string | no | (deprecated, ignored) Derived from auth token |
parent_comment_id | string | no | Parent 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
| Parameter | Type | Default | Max | Description |
|---|---|---|---|---|
cursor | string | — | — | Opaque cursor from a previous response |
limit | integer | 20 | 100 | Number 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
| Field | Type | Required | Description |
|---|---|---|---|
body | string | yes | Updated 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
Create Label
POST /api/v1/orgs/{org_id}/labels — requires auth
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Label name (min 1 char) |
color | string | yes | Hex 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
| Parameter | Type | Default | Max | Description |
|---|---|---|---|---|
cursor | string | — | — | Opaque cursor from a previous response |
limit | integer | 20 | 100 | Number 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
| Field | Type | Required | Description |
|---|---|---|---|
name | string | no | New label name |
color | string | no | New 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
| Field | Type | Required | Description |
|---|---|---|---|
ticket_ids | string[] | yes | Array 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.
| Parameter | Type | Default | Description |
|---|---|---|---|
cascade | boolean | false | Remove all ticket-label associations |
dry_run | boolean | false | With 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
| Field | Type | Required | Description |
|---|---|---|---|
label_ids | string[] | yes | Label 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
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Sprint name (min 1 char) |
goal | string | no | Sprint goal |
start_date | string | yes | ISO 8601 date |
end_date | string | yes | ISO 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
| Parameter | Type | Default | Max | Description |
|---|---|---|---|---|
cursor | string | — | — | Opaque cursor from a previous response |
limit | integer | 20 | 100 | Number 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
| Field | Type | Required | Description |
|---|---|---|---|
name | string | no | New sprint name |
goal | string | no | New sprint goal (pass null to clear) |
start_date | string | no | New start date |
end_date | string | no | New 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
| Field | Type | Required | Description |
|---|---|---|---|
ticket_ids | string[] | yes | Array 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.
| Parameter | Type | Default | Description |
|---|---|---|---|
cascade | boolean | false | Delete all tickets in the sprint |
dry_run | boolean | false | With 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:
| Mode | Behavior |
|---|---|
none | Any transition is allowed regardless of workflow rules (default) |
warn | Invalid transitions are allowed but the response includes a warning field |
strict | Invalid 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
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Workflow name (min 1 char) |
statuses | object[] | yes | Status definitions |
statuses[].name | string | yes | Status name |
statuses[].category | string | yes | todo, in_progress, done, or cancelled |
transitions | object[] | yes | Allowed transitions |
transitions[].from | string | yes | Source status name |
transitions[].to | string | yes | Target status name |
enforcement | string | no | none (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
| Parameter | Type | Default | Max | Description |
|---|---|---|---|---|
cursor | string | — | — | Opaque cursor from a previous response |
limit | integer | 20 | 100 | Number 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
| Field | Type | Required | Description |
|---|---|---|---|
name | string | no | New workflow name |
statuses | object[] | no | Replacement status definitions |
transitions | object[] | no | Replacement transition rules |
enforcement | string | no | none, 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
cascadeordry_runparameters. 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
| Field | Type | Required | Description |
|---|---|---|---|
to_status | string | yes | Target 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
| Field | Type | Required | Description |
|---|---|---|---|
ticket_ids | string[] | yes | Array of ticket UUIDs to transition |
to_status | string | yes | Target 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
| Field | Type | Required | Description |
|---|---|---|---|
user_id | string | yes | User UUID |
ticket_id | string | yes | Ticket UUID |
project_id | string | yes | Project UUID |
date | string | yes | ISO 8601 date |
duration_minutes | integer | yes | Duration in minutes |
description | string | no | Entry description |
activity_type | string | yes | Coding, 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 Param | Type | Required | Description |
|---|---|---|---|
user_id | string | no | Filter by user |
project_id | string | no | Filter by project |
date | string | no | Filter by date |
cursor | string | no | Pagination cursor |
limit | integer | no | Page 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 Param | Type | Required | Description |
|---|---|---|---|
project_id | string | no | Filter by project |
date | string | no | Filter by date |
cursor | string | no | Pagination cursor |
limit | integer | no | Page 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
| Field | Type | Required | Description |
|---|---|---|---|
date | string | no | New date |
duration_minutes | integer | no | New duration |
description | string | null | no | New description or null |
activity_type | string | no | New 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)
| Field | Type | Required | Description |
|---|---|---|---|
user_id | string | yes | User UUID |
org_id | string | yes | Organization UUID |
loaded_rate_cents | integer | yes | Loaded rate in cents per hour |
effective_date | string | yes | ISO 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 Param | Type | Required | Description |
|---|---|---|---|
cursor | string | no | Pagination cursor |
limit | integer | no | Page 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
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 Param | Type | Required | Description |
|---|---|---|---|
org_id | string | yes | Organization UUID |
entity_type | string | yes | One of: project, ticket, user, team, time_entry |
entity_id | string | yes | Entity UUID |
| Body Field | Type | Required | Description |
|---|---|---|---|
tags | array | yes | Array 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 Param | Type | Required | Description |
|---|---|---|---|
key | string | yes | Tag key to search for |
value | string | yes | Tag value to match |
cursor | string | no | Pagination cursor |
limit | integer | no | Page 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 Param | Type | Required | Description |
|---|---|---|---|
period | string | yes | Report period in YYYY-MM format |
group_by | string | no | Group results by team or user |
include_users | boolean | no | Include per-user breakdown in project entries |
include_budget | boolean | no | Include budget/ROI fields on each project |
team_id | string | no | Filter to a specific team UUID |
user_id | string | no | Filter to a specific user UUID |
cost_center_id | string | no | Filter to a specific cost center |
activity_type | string | no | Filter to a specific activity type (e.g. Coding) |
tag | string | no | Filter 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 Param | Type | Required | Description |
|---|---|---|---|
period | string | yes | Report period in YYYY-MM format |
format | string | no | Export format (default: csv) |
team_id | string | no | Filter to a specific team UUID |
user_id | string | no | Filter to a specific user UUID |
cost_center_id | string | no | Filter to a specific cost center |
activity_type | string | no | Filter to a specific activity type |
tag | string | no | Filter 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 Param | Type | Required | Description |
|---|---|---|---|
org_id | string | yes | Organization UUID |
project | string | no | Filter to a single project by key (e.g. PROJ) |
format | string | no | Export 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.
| Field | Type | Required | Description |
|---|---|---|---|
version | integer | yes | Export format version (must be 1) |
source | string | yes | Source identifier (e.g. "alloy") |
exported_at | string | yes | ISO 8601 timestamp of when the export was created |
projects | array | yes | Array of project objects |
labels | array | yes | Array of label objects |
workflows | array | yes | Array of workflow objects |
users | array | yes | Array 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
| Field | Type | Required | Description |
|---|---|---|---|
url | string | yes | Destination URL (must be valid URL) |
event_types | array | yes | Events 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
secretfield is only returned on creation. Store it securely — it is used to verify webhook signatures via theX-Alloy-Signatureheader.
List Webhooks
GET /api/v1/orgs/{org_id}/webhooks — requires auth
| Query Param | Type | Required | Description |
|---|---|---|---|
cursor | string | no | Pagination cursor |
limit | integer | no | Page 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 Param | Type | Required | Description |
|---|---|---|---|
cursor | string | no | Pagination cursor |
limit | integer | no | Page 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 Param | Type | Required | Description |
|---|---|---|---|
org_id | string | yes | Organization 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 Param | Type | Required | Description |
|---|---|---|---|
code | string | yes | Authorization code from IdP |
state | string | yes | State 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
| Field | Type | Required | Description |
|---|---|---|---|
userName | string | yes | Email address |
displayName | string | no | Display 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
| Field | Type | Required | Description |
|---|---|---|---|
displayName | string | yes | Group name |
members | array | no | Array 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_SECRETenvironment variable. Configure a GitHub webhook pointing to this endpoint with thepull_requestevent.
Receive GitHub Webhook
POST /api/v1/integrations/github/webhook — no auth required (signature verified)
| Header | Required | Description |
|---|---|---|
X-Hub-Signature-256 | yes | HMAC-SHA256 signature of the payload |
X-GitHub-Event | yes | Event 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 optionallyALLOY_SLACK_DEFAULT_CHANNELenvironment variables.
Receive Slack Command
POST /api/v1/integrations/slack/commands — no auth required (signature verified)
| Header | Required | Description |
|---|---|---|
X-Slack-Request-Timestamp | yes | Request timestamp |
X-Slack-Signature | yes | HMAC-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
| Field | Type | Required | Description |
|---|---|---|---|
notification_type | string | yes | assignment, mention, status_change, comment_sync |
ticket_id | string | yes | Ticket UUID |
channel_id | string | no | Slack channel ID |
user_slack_id | string | no | Slack user ID for DM |
message | string | no | Custom message text |
old_status | string | no | Previous status (for status_change) |
new_status | string | no | New status (for status_change) |
actor_name | string | no | Who triggered the action |
thread_ts | string | no | Slack 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.
| Field | Type | Required | Description |
|---|---|---|---|
ticket_id | string | yes | Ticket UUID |
channel_id | string | yes | Slack channel ID |
thread_ts | string | yes | Slack 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
| Field | Type | Required | Description |
|---|---|---|---|
filename | string | yes | File name |
content_type | string | yes | MIME type |
size_bytes | integer | yes | File 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
| Field | Type | Required | Description |
|---|---|---|---|
filename | string | yes | File name |
content_type | string | yes | MIME type |
size_bytes | integer | yes | File size |
s3_key | string | yes | S3 key from presign response |
uploaded_by | string | yes | User 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": "..."
}