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 TriagedFixingVerifyingClosed).

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