Workflows & Statuses
Workflows define the set of statuses a ticket can move through and which transitions are allowed between them. Each organization gets a Default workflow automatically, but you can create custom workflows with strict enforcement to prevent invalid state changes. This guide walks through the full workflow lifecycle: creating, assigning, transitioning, and enforcing.
1. Prerequisites
You need a running Alloy server and a registered user. Set these shell variables for the examples below:
BASE_URL="http://localhost:3000"
Register a user and capture the token:
curl -s -X POST "$BASE_URL/api/v1/auth/register" \
-H "Content-Type: application/json" \
-d '{
"email": "guide-wf@alloy.dev",
"password": "guide-wf-pass1",
"display_name": "WF Guide User"
}' | jq .
{
"user_id": "...",
"email": "guide-wf@alloy.dev",
"display_name": "WF Guide User",
"access_token": "..."
}
Save the token and user ID:
TOKEN="<access_token from above>"
USER_ID="<user_id from above>"
Create an organization:
curl -s -X POST "$BASE_URL/api/v1/orgs" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"name": "Workflow Guide Org",
"slug": "guide-wf-org"
}' | jq .
{
"id": "...",
"name": "Workflow Guide Org",
"slug": "guide-wf-org"
}
ORG_ID="<id from above>"
Create a project to use for ticket transitions later:
curl -s -X POST "$BASE_URL/api/v1/projects" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d "{
\"org_id\": \"$ORG_ID\",
\"key\": \"WF\",
\"name\": \"Workflow Demo Project\",
\"description\": \"Project for the workflows guide\"
}" | jq .
{
"id": "...",
"org_id": "...",
"key": "WF",
"name": "Workflow Demo Project",
"description": "Project for the workflows guide",
"ticket_counter": 0
}
PROJECT_ID="<id from above>"
2. The Default Workflow
Every organization gets a Default workflow when it is created. This
workflow has six statuses and allows any-to-any transitions (enforcement
mode none).
List the workflows in your organization:
curl -s "$BASE_URL/api/v1/orgs/$ORG_ID/workflows" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"items": [
{
"id": "...",
"org_id": "...",
"name": "Default",
"statuses": [
{ "name": "Backlog", "category": "backlog" },
{ "name": "Todo", "category": "todo" },
{ "name": "InProgress", "category": "in_progress" },
{ "name": "InReview", "category": "in_review" },
{ "name": "Done", "category": "done" },
{ "name": "Cancelled", "category": "cancelled" }
],
"transitions": "...",
"enforcement": "none",
"created_at": "...",
"updated_at": "..."
}
],
"next_cursor": null,
"has_more": false
}
The default statuses and their categories:
| Status | Category | Meaning |
|---|---|---|
| Backlog | backlog | Captured but not yet planned |
| Todo | todo | Planned and ready to start |
| InProgress | in_progress | Actively being worked on |
| InReview | in_review | Work complete, awaiting review |
| Done | done | Finished |
| Cancelled | cancelled | Will not be done |
With enforcement: "none", tickets can move freely between any statuses.
3. Create a Custom Workflow
Create a workflow with specific statuses, allowed transitions, and strict enforcement so that only defined transitions are permitted.
curl -s -X POST "$BASE_URL/api/v1/orgs/$ORG_ID/workflows" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"name": "Bug Triage",
"statuses": [
{ "name": "New", "category": "backlog" },
{ "name": "Triaged", "category": "todo" },
{ "name": "Fixing", "category": "in_progress" },
{ "name": "Verifying", "category": "in_review" },
{ "name": "Closed", "category": "done" },
{ "name": "WontFix", "category": "cancelled" }
],
"transitions": [
{ "from": "New", "to": "Triaged" },
{ "from": "New", "to": "WontFix" },
{ "from": "Triaged", "to": "Fixing" },
{ "from": "Triaged", "to": "WontFix" },
{ "from": "Fixing", "to": "Verifying" },
{ "from": "Fixing", "to": "Triaged" },
{ "from": "Verifying", "to": "Closed" },
{ "from": "Verifying", "to": "Fixing" }
],
"enforcement": "strict"
}' | jq .
{
"id": "...",
"org_id": "...",
"name": "Bug Triage",
"statuses": [
{ "name": "New", "category": "backlog" },
{ "name": "Triaged", "category": "todo" },
{ "name": "Fixing", "category": "in_progress" },
{ "name": "Verifying", "category": "in_review" },
{ "name": "Closed", "category": "done" },
{ "name": "WontFix", "category": "cancelled" }
],
"transitions": [
{ "from": "New", "to": "Triaged" },
{ "from": "New", "to": "WontFix" },
{ "from": "Triaged", "to": "Fixing" },
{ "from": "Triaged", "to": "WontFix" },
{ "from": "Fixing", "to": "Verifying" },
{ "from": "Fixing", "to": "Triaged" },
{ "from": "Verifying", "to": "Closed" },
{ "from": "Verifying", "to": "Fixing" }
],
"enforcement": "strict",
"created_at": "...",
"updated_at": "..."
}
Save the workflow ID:
WORKFLOW_ID="<id from above>"
The enforcement field controls how strictly transitions are validated:
| Mode | Invalid Transition | HTTP Status |
|---|---|---|
| none | Allowed silently | 200 |
| warn | Allowed with warning in response | 200 |
| strict | Rejected | 422 |
4. Get & Update a Workflow
Fetch a single workflow by ID:
curl -s "$BASE_URL/api/v1/workflows/$WORKFLOW_ID" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"id": "...",
"org_id": "...",
"name": "Bug Triage",
"statuses": "...",
"transitions": "...",
"enforcement": "strict",
"created_at": "...",
"updated_at": "..."
}
Update the workflow name or enforcement mode with a PATCH request. Only include the fields you want to change:
curl -s -X PATCH "$BASE_URL/api/v1/workflows/$WORKFLOW_ID" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{"name": "Bug Triage v2"}' | jq .
{
"id": "...",
"org_id": "...",
"name": "Bug Triage v2",
"statuses": "...",
"transitions": "...",
"enforcement": "strict",
"created_at": "...",
"updated_at": "..."
}
You can also update statuses, transitions, and enforcement in a single
PATCH call. Updating statuses replaces the full list, so always include all
statuses you want to keep.
CLI shortcut:
alloy workflow get "$WORKFLOW_ID" alloy workflow update "$WORKFLOW_ID" --name "Bug Triage v2"
5. Assign a Workflow to a Project
Projects without a workflow use the organization’s Default workflow.
To assign a custom workflow, update the project’s workflow_id. Currently
this is done when creating the project or through the Alloy MCP tools:
# Via MCP tool
alloy project update "$PROJECT_ID" --workflow-id "$WORKFLOW_ID"
Once assigned, all ticket transitions within that project are validated against the custom workflow’s rules and enforcement mode.
6. Transition Tickets
Create a ticket in the project, then transition it through statuses. With
the Default workflow (enforcement none), any transition is allowed:
curl -s -X POST "$BASE_URL/api/v1/projects/$PROJECT_ID/tickets" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d "{
\"title\": \"Fix login timeout\",
\"description\": \"Users report session expiring too early\",
\"priority\": \"High\",
\"reporter_id\": \"$USER_ID\"
}" | jq .
{
"id": "...",
"project_id": "...",
"ticket_number": 1,
"title": "Fix login timeout",
"description": "Users report session expiring too early",
"status": "Backlog",
"priority": "High",
"reporter_id": "..."
}
TICKET_ID="<id from above>"
Transition the ticket to InProgress:
curl -s -X POST "$BASE_URL/api/v1/tickets/$TICKET_ID/transition" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{"to_status": "InProgress"}' | jq .
{
"id": "...",
"project_id": "...",
"ticket_number": 1,
"title": "Fix login timeout",
"status": "InProgress",
"priority": "High"
}
Check which transitions are available from the current status:
curl -s "$BASE_URL/api/v1/tickets/$TICKET_ID/transitions" \
-H "Authorization: Bearer $TOKEN" | jq .
{
"current_status": "InProgress",
"available": [
{
"name": "...",
"category": "..."
}
]
}
With enforcement: "none", all statuses appear as available transitions.
With stricter enforcement, only explicitly defined transitions appear.
CLI shortcut:
alloy ticket transition "$TICKET_ID" --to-status InProgress
7. Strict Mode — Rejected Transitions
When a workflow uses enforcement: "strict", transitions that are not
explicitly defined in the workflow’s transitions list are rejected with
a 422 Unprocessable Entity error.
For example, in the “Bug Triage” workflow created above, going directly from New to Closed is not allowed (you must go through Triaged → Fixing → Verifying → Closed).
Attempting an invalid transition returns a 422:
curl -s -w "\nHTTP_STATUS: %{http_code}\n" \
-X POST "$BASE_URL/api/v1/tickets/$TICKET_ID/transition" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{"to_status": "Done"}' | head -20
With strict enforcement active, the response would be:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "..."
}
}
The ticket stays in its current status. The caller must follow the defined transition path.
Warn mode is a middle ground: invalid transitions succeed but the
response includes a warning field alerting the caller:
{
"id": "...",
"status": "Done",
"warning": "..."
}
This lets teams gradually adopt workflow rules without blocking anyone.
8. Delete a Workflow
Admins can delete a workflow. If projects reference the workflow, the API
returns a 409 Conflict error:
curl -s -X DELETE "$BASE_URL/api/v1/workflows/$WORKFLOW_ID" \
-H "Authorization: Bearer $TOKEN"
Returns 204 No Content. To delete a workflow that is in use, first
reassign or remove the workflow from all projects that reference it.
CLI shortcut:
alloy workflow delete "$WORKFLOW_ID"
Learn More
- Projects & Tickets — create tickets that follow your workflow statuses
- Sprints & Boards — move tickets through statuses during sprint execution
- Labels, Tags & Organization — complement workflows with labels for categorization
- For Engineering Managers — workflow customization from an EM’s perspective