> ## Documentation Index
> Fetch the complete documentation index at: https://docs.ilyama.golain.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Your first ticket, end to end

> Create a form and a workflow, activate it, then open, assign, move, and close a ticket with the API, checking its history at the end.

This walkthrough takes one ticket through its whole life using `curl`. You build a **maintenance-request** workflow with four states, attach a **resolution-notes** form to the closing step, and then run a real ticket from `new` to `resolved`.

```mermaid theme={null}
flowchart LR
  new([new · open]) --> in_progress([in_progress · in_progress])
  new --> cancelled([cancelled · cancelled])
  in_progress -->|"form: resolution-notes"| resolved([resolved · terminal_success])
  in_progress --> cancelled
```

Every step also has a short **Console** alternative. See [Ticketing in the Console](/ticketing/console) for the full UI guide.

## Before you start

| You need | Why |
| - | - |
| A project you belong to | Tickets, workflows, and forms are project-scoped |
| **Manage workflows** on that project | Required to create forms, create workflows, and activate them |
| `curl` and `jq` | The examples capture IDs with `jq` |

Creating, assigning, and transitioning tickets needs only project membership.

**1. Set environment variables**

```bash theme={null}
export API=https://api.ilyama.golain.io/core/api/v1
export TOKEN=…          # OIDC access token (same as Console)
export ORG_ID=…         # Organization UUID
export PROJECT_ID=…
```

Every request sends these headers:

| Header | Value |
| - | - |
| `Authorization` | `Bearer $TOKEN` |
| `ORG-ID` | `$ORG_ID` |
| `Content-Type` | `application/json` (on requests with a body) |

Successful responses use the envelope `{"ok": 1, "data": {...}}`. Errors return `{"ok": 0, "message": "…", "detail": "…"}` with an HTTP 4xx or 5xx status.

> [!NOTE]
>
> **Console:** you don't need tokens. Sign in, open your project, and go to **Operations → Ticketing**.

**2. Create the resolution-notes form**

A catalog form is a JSON Schema plus **bindings** that copy submitted fields into the ticket. This one requires a `resolution` string and writes it to `metadata.resolution`.

```bash theme={null}
curl -sS -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "ORG-ID: $ORG_ID" \
  -H "Content-Type: application/json" \
  -d '{
    "form": {
      "name": "resolution-notes",
      "schema": {
        "type": "object",
        "required": ["resolution"],
        "properties": {
          "resolution": { "type": "string", "minLength": 3 }
        }
      },
      "bindings": [
        {
          "field": "resolution",
          "writes_to": { "kind": "metadata", "metadata_path": "resolution" }
        }
      ]
    }
  }' \
  "$API/projects/$PROJECT_ID/ticket-forms"
```

```json theme={null}
{
  "ok": 1,
  "data": {
    "item": {
      "id": "3c9b8a1e-5f2d-4b7a-9e61-0d2f4c8a7b13",
      "org_id": "…",
      "project_id": "…",
      "name": "resolution-notes",
      "version": 1,
      "spec": {
        "name": "resolution-notes",
        "schema": { "type": "object", "required": ["resolution"], "properties": { "resolution": { "type": "string", "minLength": 3 } } },
        "bindings": [{ "field": "resolution", "writes_to": { "kind": "metadata", "metadata_path": "resolution" } }]
      },
      "validated_at": "2026-10-05T09:02:11.418204Z",
      "validation_report": { "ok": true, "errors": null },
      "created_by": "5e0c2d7a-8b31-4c9f-a6e4-2f1b9d3c7e58",
      "created_at": "2026-10-05T09:02:11.418204Z"
    }
  }
}
```

The workflow refers to this form by `name`. Posting the same name again, or patching the form, creates version 2. A `require_form` with no `version` always uses the latest version. See [Forms](/ticketing/forms) for pinning.

> [!WARNING]
>
> Every field you bind must be present in the submitted payload, even if the schema marks it optional. Bind only fields you will always send.

> [!NOTE]
>
> **Console:** go to **Ticketing → Forms**, click **New form**, paste the YAML below, check **Schema preview**, then click **Create form**.
>
> ```yaml theme={null}
> name: resolution-notes
> schema:
>   type: object
>   required: [resolution]
>   properties:
>     resolution:
>       type: string
>       minLength: 3
> bindings:
>   - field: resolution
>     writes_to:
>       kind: metadata
>       metadata_path: resolution
> ```

**3. Validate the workflow**

`POST /ticket-workflows/validate` checks a spec without saving it. Send the same `{"workflow": {...}}` body you will use to create it.

```bash theme={null}
cat > maintenance-request.json <<'EOF'
{
  "workflow": {
    "name": "maintenance-request",
    "initial": "new",
    "labels": {
      "extra": [
        { "key": "new",         "class": "open",             "color": "#64748b" },
        { "key": "in_progress", "class": "in_progress",      "color": "#2563eb" },
        { "key": "resolved",    "class": "terminal_success", "color": "#16a34a", "terminal": true },
        { "key": "cancelled",   "class": "cancelled",        "color": "#9ca3af", "terminal": true }
      ]
    },
    "validation": {
      "on_create": { "require_tag_kinds": ["severity"] },
      "before_transition": [
        { "from": "in_progress", "to": "resolved", "require_form": { "name": "resolution-notes" } }
      ]
    },
    "transitions": [
      { "from": "new",         "to": "in_progress", "permit": ["project.member"] },
      { "from": "new",         "to": "cancelled",   "permit": ["project.member"] },
      { "from": "in_progress", "to": "resolved",    "permit": ["ticket.assignee"] },
      { "from": "in_progress", "to": "cancelled",   "permit": ["project.member"] }
    ]
  }
}
EOF

curl -sS -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "ORG-ID: $ORG_ID" \
  -H "Content-Type: application/json" \
  -d @maintenance-request.json \
  "$API/projects/$PROJECT_ID/ticket-workflows/validate"
```

What this spec says:

| Part | Meaning |
| - | - |
| `labels.extra` | Four states. `class` must be one of `open`, `in_progress`, `terminal_success`, `terminal_failed`, `cancelled`. `resolved` and `cancelled` close the ticket |
| `validation.on_create` | A ticket must be created with at least one `severity` tag |
| `validation.before_transition` | Moving `in_progress` → `resolved` requires a `resolution-notes` payload |
| `transitions[].permit` | Who may make each move. Every transition needs at least one entry. `project.member` allows any project member; `ticket.assignee` allows the assigned user (or a user tagged on the ticket) |

A valid spec returns:

```json theme={null}
{
  "ok": 1,
  "data": { "valid": true, "errors": null, "warnings": null }
}
```

Validation problems still return HTTP `200`, with `valid: false` and one entry per problem. For example, a typo `"to": "resolvd"` in the third transition returns:

```json theme={null}
{
  "ok": 1,
  "data": {
    "valid": false,
    "errors": [
      { "path": "transitions[2].to", "message": "unknown to \"resolvd\"" },
      { "path": "labels", "message": "G1: label \"resolved\" is not reachable from initial \"new\"" }
    ],
    "warnings": null
  }
}
```

The graph checks require that every label is reachable from `initial`, every non-terminal label has an outgoing transition, and at least one path reaches a terminal label. See [Workflows](/ticketing/workflows) for the full rule list. A body that is not a parseable workflow (for example, a missing `name` or `initial`) returns HTTP `400` instead.

> [!NOTE]
>
> **Console:** go to **Ticketing → Workflows**, click **New workflow**, open the **YAML** tab, paste the spec (the object inside `"workflow"`, written as YAML), and click **Validate**. A valid spec shows **YAML OK**. The **Graph** tab draws the states and transitions.

**4. Create the workflow**

Post the same body to `/ticket-workflows`. Capture two IDs from the response:

| Field | Variable | Used for |
| - | - | - |
| `data.item.id` | `WF_VERSION_ID` | This **version**: activate, patch, delete |
| `data.item.workflow_def_id` | `WF_DEF_ID` | The **workflow** across all versions: create tickets |

```bash theme={null}
RESP=$(curl -sS -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "ORG-ID: $ORG_ID" \
  -H "Content-Type: application/json" \
  -d @maintenance-request.json \
  "$API/projects/$PROJECT_ID/ticket-workflows")

export WF_VERSION_ID=$(echo "$RESP" | jq -r '.data.item.id')
export WF_DEF_ID=$(echo "$RESP" | jq -r '.data.item.workflow_def_id')
echo "$RESP" | jq
```

```json theme={null}
{
  "ok": 1,
  "data": {
    "item": {
      "id": "b24e7d91-0c6a-4f3e-8d15-7a9c2e4b6f81",
      "workflow_def_id": "6a1f0c3e-92d4-4e8b-b7a5-1c3d5e7f9a20",
      "workflow_name": "maintenance-request",
      "version": 1,
      "is_active": false,
      "spec": { "name": "maintenance-request", "initial": "new", "…": "…" },
      "validated_at": "2026-10-05T09:04:37.902115Z",
      "validation_report": { "ok": true, "errors": null },
      "created_by": "5e0c2d7a-8b31-4c9f-a6e4-2f1b9d3c7e58",
      "created_at": "2026-10-05T09:04:37.902115Z"
    }
  }
}
```

A new version starts inactive. Posting another spec with the same `name` adds version 2 under the same `workflow_def_id`.

> [!WARNING]
>
> Create saves the version even when the spec has validation errors; they appear in `validation_report` with `"ok": false`. Such a version cannot be activated. Validate first.

> [!NOTE]
>
> **Console:** click **Save** in the workflow editor. Saving creates a draft version; the status line shows **Not active yet**.

**5. Activate the version**

Tickets can only be created on a workflow that has an active version. Activate by **version** ID:

```bash theme={null}
curl -sS -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "ORG-ID: $ORG_ID" \
  "$API/projects/$PROJECT_ID/ticket-workflows/$WF_VERSION_ID/activate"
```

The response is the same version item with `"is_active": true`. Activating a later version moves new tickets to it; tickets already open stay on the version they started on.

Optionally, list the states the version created:

```bash theme={null}
curl -sS \
  -H "Authorization: Bearer $TOKEN" \
  -H "ORG-ID: $ORG_ID" \
  "$API/projects/$PROJECT_ID/ticket-workflows/$WF_VERSION_ID/state-labels"
```

Each item has `id`, `label`, `system_class`, `color`, `is_initial`, and `is_terminal`. `new` has `"is_initial": true`; `resolved` and `cancelled` have `"is_terminal": true`.

> [!NOTE]
>
> **Console:** click **Activate** and confirm the dialog. The workflow now appears on **Board**.

**6. Create a ticket**

Use `workflow_def_id`, not the version ID. Include a `severity` tag, because the workflow requires one on create.

```bash theme={null}
RESP=$(curl -sS -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "ORG-ID: $ORG_ID" \
  -H "Content-Type: application/json" \
  -d '{
    "workflow_def_id": "'"$WF_DEF_ID"'",
    "title": "Conveyor belt slipping on packaging line 2",
    "body": "Operator reports intermittent slipping under full load since the morning shift.",
    "initial_tags": [
      { "kind": "severity", "value": "high", "label": "High" }
    ],
    "metadata": { "location": "Packaging line 2" },
    "due_at": "2026-10-08T17:00:00Z"
  }' \
  "$API/projects/$PROJECT_ID/tickets")

export TICKET_ID=$(echo "$RESP" | jq -r '.data.item.id')
export MY_USER_ID=$(echo "$RESP" | jq -r '.data.item.created_by')
echo "$RESP" | jq
```

```json theme={null}
{
  "ok": 1,
  "data": {
    "item": {
      "id": "d7e3a5c2-1b84-4f69-a0d2-9c6e8b4f3a17",
      "org_id": "…",
      "project_id": "…",
      "workflow_version_id": "b24e7d91-0c6a-4f3e-8d15-7a9c2e4b6f81",
      "state_label_id": "0f5a9c7e-3d21-4b86-a4e9-6c2b8d1f7e34",
      "system_class": "open",
      "title": "Conveyor belt slipping on packaging line 2",
      "body": "Operator reports intermittent slipping under full load since the morning shift.",
      "assigned_group_id": null,
      "opened_at": "2026-10-05T09:07:15.226481Z",
      "closed_at": null,
      "due_at": "2026-10-08T17:00:00Z",
      "metadata": "eyJsb2NhdGlvbiI6ICJQYWNrYWdpbmcgbGluZSAyIn0=",
      "created_by": "5e0c2d7a-8b31-4c9f-a6e4-2f1b9d3c7e58",
      "created_at": "2026-10-05T09:07:15.226481Z",
      "updated_at": "2026-10-05T09:07:15.226481Z",
      "assigned_to": null,
      "number": 1
    }
  }
}
```

| Field | Notes |
| - | - |
| `workflow_def_id`, `title` | Required |
| `initial_tags` | `{kind, value, label?}`. Without a `severity` tag, this workflow rejects the create with `400` |
| `due_at` | RFC 3339. This is the field that drives [due policy](/ticketing/automation) timers |
| `created_by` | Your user ID. The example saves it as `MY_USER_ID` for the next step |

> [!WARNING]
>
> Single-ticket responses return `metadata` as a **base64-encoded JSON string**. Decode it with `jq -r '.data.item.metadata' | base64 -d`, which prints `{"location": "Packaging line 2"}`. To get decoded metadata, list with `fields`: `GET …/tickets?ids=$TICKET_ID&fields=id,title,metadata,state_label`.

Ticket create and transition accept an `Idempotency-Key` header. Send one when you retry so a timed-out request does not create a second ticket.

> [!NOTE]
>
> **Console:** on **Board**, click **New ticket**, fill **Title** and **Description**, choose a value under **Severity**, and click **Create**. The Console's **Due date** is stored as `metadata.due_at`, not the API `due_at` field, so it does not start due-policy timers.

**7. Assign the ticket**

The `in_progress` → `resolved` transition permits `ticket.assignee`, so assign the ticket to yourself. Send exactly one of `assigned_user_id` or `assigned_group_id`.

```bash theme={null}
curl -sS -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "ORG-ID: $ORG_ID" \
  -H "Content-Type: application/json" \
  -d '{ "assigned_user_id": "'"$MY_USER_ID"'" }' \
  "$API/projects/$PROJECT_ID/tickets/$TICKET_ID/assign"
```

```json theme={null}
{ "ok": 1, "data": { "ok": true } }
```

The ticket's `assigned_to` is now your user ID. The user must be a member of the project. Assigning a user clears any group assignment, and the reverse. To undo, `POST …/unassign` with `{"target": "user"}`. You can also assign at create time with `assigned_user_id` or `assigned_group_id`. See [Assignment & notifications](/ticketing/assignment-notifications).

> [!NOTE]
>
> **Console:** there is no assign control. Pick people under **Assign users** when you create the ticket instead. That adds `user` tags, and a `ticket.assignee` permit also accepts users tagged on the ticket, so the walkthrough still works.

**8. Start work: new → in\_progress**

Transition by the target label **key**:

```bash theme={null}
curl -sS -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "ORG-ID: $ORG_ID" \
  -H "Content-Type: application/json" \
  -d '{ "to_label": "in_progress", "reason": "Technician on site" }' \
  "$API/projects/$PROJECT_ID/tickets/$TICKET_ID/transition"
```

```json theme={null}
{ "ok": 1, "data": { "ok": true } }
```

The call returns after the state change is saved, so a `GET` right afterwards shows `"system_class": "in_progress"`. Any `on_enter` / `on_exit` actions on the transition run afterwards in the background (see [Automation](/ticketing/automation)). This workflow has none.

| Response | Cause |
| - | - |
| `400` `no such transition` | No transition from the current label to `to_label` |
| `403` `transition permit denied` | You don't match any `permit` entry |
| `400` `guard failed` | A `guards` expression returned false |

> [!NOTE]
>
> **Console:** open the ticket and click **Go** next to `in_progress` under **Transitions**, or drag the card to the **in\_progress** column on **Board**.

**9. Resolve with the form: in\_progress → resolved**

This move requires the `resolution-notes` form. Send its fields in `form_payload`:

```bash theme={null}
curl -sS -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "ORG-ID: $ORG_ID" \
  -H "Content-Type: application/json" \
  -d '{
    "to_label": "resolved",
    "reason": "Belt replaced and tested under load",
    "form_payload": { "resolution": "Replaced worn drive belt" }
  }' \
  "$API/projects/$PROJECT_ID/tickets/$TICKET_ID/transition"
```

```json theme={null}
{ "ok": 1, "data": { "ok": true } }
```

The payload is checked against the form schema, then the binding writes `resolution` into the ticket metadata. Nothing changes if any check fails:

| Response | Cause |
| - | - |
| `400` `transition in_progress -> resolved requires form payload` | `form_payload` missing or empty |
| `400` `form payload failed validation` | Payload does not match the schema (for example, `resolution` shorter than 3 characters) |
| `403` `transition permit denied` | You are not the assignee and not tagged on the ticket |

> [!NOTE]
>
> **Console:** click **Go** next to `resolved` (or drag the card). The **Transition form** dialog (or **Form required to move** on the board) asks for **resolution**; fill it in and click **Apply transition** (or **Move**).

**10. Check the history and final state**

List the transition history. Rows are newest first:

```bash theme={null}
curl -sS \
  -H "Authorization: Bearer $TOKEN" \
  -H "ORG-ID: $ORG_ID" \
  "$API/projects/$PROJECT_ID/tickets/$TICKET_ID/transitions"
```

```json theme={null}
{
  "ok": 1,
  "data": {
    "items": [
      {
        "id": "a83f1d6b-2c95-4e07-b1a8-5d4c7e9f0b26",
        "ticket_id": "d7e3a5c2-1b84-4f69-a0d2-9c6e8b4f3a17",
        "from_label": "in_progress",
        "from_label_id": "8c2e4a6f-1d3b-4e59-97a0-b5c7d9e1f342",
        "to_label": "resolved",
        "to_label_id": "e9b1c3d5-7f20-4a84-86c6-a2e4f6b8d013",
        "actor_kind": "user",
        "actor_id": "5e0c2d7a-8b31-4c9f-a6e4-2f1b9d3c7e58",
        "reason": "Belt replaced and tested under load",
        "payload": { "resolution": "Replaced worn drive belt" },
        "occurred_at": "2026-10-05T10:41:52.730918Z"
      },
      {
        "id": "4f6d8b0a-3e17-4c29-a5b1-7e9c1d3f5a68",
        "ticket_id": "d7e3a5c2-1b84-4f69-a0d2-9c6e8b4f3a17",
        "from_label": "new",
        "from_label_id": "0f5a9c7e-3d21-4b86-a4e9-6c2b8d1f7e34",
        "to_label": "in_progress",
        "to_label_id": "8c2e4a6f-1d3b-4e59-97a0-b5c7d9e1f342",
        "actor_kind": "user",
        "actor_id": "5e0c2d7a-8b31-4c9f-a6e4-2f1b9d3c7e58",
        "reason": "Technician on site",
        "payload": {},
        "occurred_at": "2026-10-05T09:12:03.481233Z"
      }
    ],
    "meta": { "currentPage": 1, "itemsPerPage": 10, "totalItems": 2, "totalPages": 1 }
  }
}
```

Page with `limit` (default `10`, max `200`) and `page`. Each row stores the submitted `form_payload` as `payload`.

Then fetch the ticket:

```bash theme={null}
curl -sS \
  -H "Authorization: Bearer $TOKEN" \
  -H "ORG-ID: $ORG_ID" \
  "$API/projects/$PROJECT_ID/tickets/$TICKET_ID"
```

```json theme={null}
{
  "ok": 1,
  "data": {
    "item": {
      "id": "d7e3a5c2-1b84-4f69-a0d2-9c6e8b4f3a17",
      "state_label_id": "e9b1c3d5-7f20-4a84-86c6-a2e4f6b8d013",
      "system_class": "terminal_success",
      "title": "Conveyor belt slipping on packaging line 2",
      "opened_at": "2026-10-05T09:07:15.226481Z",
      "closed_at": "2026-10-05T10:41:52.730918Z",
      "due_at": "2026-10-08T17:00:00Z",
      "metadata": "eyJsb2NhdGlvbiI6ICJQYWNrYWdpbmcgbGluZSAyIiwgInJlc29sdXRpb24iOiAiUmVwbGFjZWQgd29ybiBkcml2ZSBiZWx0In0=",
      "assigned_to": "5e0c2d7a-8b31-4c9f-a6e4-2f1b9d3c7e58",
      "number": 1,
      "…": "…"
    }
  }
}
```

| Field | What changed |
| - | - |
| `system_class` | `terminal_success`, the class of the `resolved` label |
| `closed_at` | Set when the ticket entered a terminal label |
| `metadata` | Decodes to `{"location": "Packaging line 2", "resolution": "Replaced worn drive belt"}`. The form binding merged `resolution` into the existing metadata |

> [!NOTE]
>
> **Console:** the ticket moves to the **resolved** column, and **Ticket detail** shows **Status** `terminal_success`. The Console has no transition-history view; use the API for the audit trail.

## Next steps

* [Workflows](/ticketing/workflows): guards, permits, trigger intake, and due policy
* [Forms](/ticketing/forms): pinned versions, inline forms, and tag bindings
* [Assignment & notifications](/ticketing/assignment-notifications): group assignment and ticket notification events
* [Automation](/ticketing/automation): HTTP and device RPC actions on transitions
* [API reference](/ticketing/api-reference): every ticketing endpoint
* [Troubleshooting](/ticketing/troubleshooting): common errors and fixes


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.