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

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:

StatusCategoryMeaning
BacklogbacklogCaptured but not yet planned
TodotodoPlanned and ready to start
InProgressin_progressActively being worked on
InReviewin_reviewWork complete, awaiting review
DonedoneFinished
CancelledcancelledWill 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:

ModeInvalid TransitionHTTP Status
noneAllowed silently200
warnAllowed with warning in response200
strictRejected422

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