> ## 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.

# Ticketing API reference

> HTTP routes for ticket forms, workflows, workflow templates, tickets, transitions, assignment, and tags, with permissions and response shapes.

All ticketing routes live under a Project on the management API. Set these before running the examples:

```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=…
```

| Resource | Base path |
| - | - |
| Forms | `$API/projects/{project_id}/ticket-forms` |
| Workflows | `$API/projects/{project_id}/ticket-workflows` |
| Workflow templates | `$API/projects/{project_id}/ticket-workflow-templates` |
| Tickets | `$API/projects/{project_id}/tickets` |

For concepts, see [Ticketing overview](/ticketing/overview). For the workflow and form spec, see [Workflows](/ticketing/workflows) and [Forms](/ticketing/forms).

## Authentication

Every route requires:

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

Form and workflow bodies (`POST`/`PATCH` on `ticket-forms` and `ticket-workflows`, plus `/validate`) also accept YAML. Send `Content-Type: application/yaml` (or `text/yaml`, `application/x-yaml`) and keep the same top-level key (`form:` or `workflow:`).

### Idempotency

Send an `Idempotency-Key` header to make retries safe on these routes: create, update, and delete ticket; transition; assign; unassign; remove tag; instantiate template. A retry with the same key while the first request is still running returns **409** `in progress`.

## Permissions

| Operation | Permission |
| - | - |
| All `GET` routes | Project read access (results are filtered to what you can see) |
| `POST`, `PATCH`, `DELETE` on `ticket-forms` | Project **manage workflows** |
| `POST`, `PATCH`, `DELETE` on `ticket-workflows`, `POST …/activate` | Project **manage workflows** |
| `POST …/ticket-workflows/validate` | Project member |
| All ticket mutations (create, patch, delete, transition, assign, unassign, tags) | Project member |
| `POST …/ticket-workflow-templates/{template_key}/instantiate` | Project **manage assets** |

Transitions also check the transition's `permit` list in the workflow. A Project member who does not match any permit gets **403** `transition permit denied`. See [Workflows](/ticketing/workflows) for permit values.

## Rate limits

| Route type | Limit |
| - | - |
| Reads (`GET`) | 64 req/s |
| Creates (`POST` forms, workflows, `/validate`, tickets, template instantiate) | 12 req/s |
| Updates and deletes (`PATCH`, `DELETE`, activate, transition, assign, unassign, tags) | 32 req/s |

Back off on `429`.

## Response envelopes

| Shape | Returned by |
| - | - |
| `{"ok": 1, "data": {"item": {…}}}` | Single-item reads, creates, and patches; activate; instantiate |
| `{"ok": 1, "data": {"items": […], "meta": {…}}}` | Paginated lists |
| `{"ok": 1, "data": {"items": […]}}` | State labels, workflow templates (not paginated) |
| `{"ok": 1}` | Deletes and tag removal |
| `{"ok": 1, "data": {"ok": true}}` | Transition, assign, unassign |
| `{"ok": 1, "data": {"tagId": "…"}}` | Add tag |
| `{"ok": 0, "message": "…", "detail": "…"}` | Errors |

Paginated lists accept `page` (default `1`), `limit` (default `10`, max `200`), and `offset` (used only when `page` is absent). `meta` contains:

| Field | Description |
| - | - |
| `currentPage` | Page returned |
| `itemsPerPage` | Effective `limit` |
| `totalItems` | Total matching rows |
| `totalPages` | `ceil(totalItems / itemsPerPage)` |

***

## Forms

Forms are versioned by `name`. `POST` with a name that already exists, or `PATCH` on any version, writes the next version. Older versions stay intact. A workflow `require_form` without a `version` uses the latest version.

### List forms

```
GET /core/api/v1/projects/{project_id}/ticket-forms?page=&limit=
```

**Permission:** Project read

```bash theme={null}
curl -sS \
  -H "Authorization: Bearer $TOKEN" \
  -H "ORG-ID: $ORG_ID" \
  "$API/projects/$PROJECT_ID/ticket-forms?limit=50"
```

**Response:** `data.items[]` of [form items](#form-item) plus `data.meta`.

### Get form

```
GET /core/api/v1/projects/{project_id}/ticket-forms/{form_id}
```

**Permission:** Project read

`form_id` is the id of one form version.

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

**Response:** `data.item` — a [form item](#form-item). **404** `form not found` if the id is not in this Project.

### Create form

```
POST /core/api/v1/projects/{project_id}/ticket-forms
```

**Permission:** Project **manage workflows**

**Request body:**

| Field | Type | Required | Description |
| - | - | - | - |
| `form.name` | string | Yes | Form name; reuse it to add a version |
| `form.schema` | object | Yes | JSON Schema for the submitted payload |
| `form.bindings` | array | No | `[{ field, writes_to: { kind, tag_kind?, metadata_path? } }]` |

`writes_to.kind` is `tag` (needs `tag_kind`) or `metadata` (needs `metadata_path`, dotted paths allowed).

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

**Response:**

```json theme={null}
{
  "ok": 1,
  "data": {
    "item": {
      "id": "6f1c2a8e-4b1d-4e0a-9c55-0d3c1e7b9a21",
      "org_id": "…",
      "project_id": "…",
      "name": "maintenance_details",
      "version": 1,
      "spec": { "name": "maintenance_details", "schema": { "…": "…" }, "bindings": [ "…" ] },
      "validated_at": "2026-10-05T09:12:44.120Z",
      "validation_report": { "ok": true, "errors": null },
      "created_by": "user-subject-id",
      "created_at": "2026-10-05T09:12:44.120Z"
    }
  }
}
```

A missing `name` or `schema`, or a bad binding, returns **400**.

### Update form

```
PATCH /core/api/v1/projects/{project_id}/ticket-forms/{form_id}
```

**Permission:** Project **manage workflows**

**Request body:** same as create. `form.name` must match the existing form (**400** `spec name must match existing form name`).

```bash theme={null}
curl -sS -X PATCH \
  -H "Authorization: Bearer $TOKEN" \
  -H "ORG-ID: $ORG_ID" \
  -H "Content-Type: application/json" \
  -d '{"form":{"name":"maintenance_details","schema":{"type":"object","required":["location","note"],"properties":{"location":{"type":"string"},"note":{"type":"string"}}}}}' \
  "$API/projects/$PROJECT_ID/ticket-forms/$FORM_ID"
```

**Response:** `data.item` — the **new** version, with a new `id` and `version` incremented by one.

### Delete form

```
DELETE /core/api/v1/projects/{project_id}/ticket-forms/{form_id}
```

**Permission:** Project **manage workflows**

Deletes that one version.

```bash theme={null}
curl -sS -X DELETE \
  -H "Authorization: Bearer $TOKEN" \
  -H "ORG-ID: $ORG_ID" \
  "$API/projects/$PROJECT_ID/ticket-forms/$FORM_ID"
```

**Response:** `{"ok": 1}`

***

## Workflows

A workflow has a stable **definition** (`workflow_def_id`, one per `name`) and immutable **versions** (`id`). Creating or patching writes a new version that is **inactive**. Activate a version before you create tickets against the definition. Only one version per definition is active.

### List workflow versions

```
GET /core/api/v1/projects/{project_id}/ticket-workflows?page=&limit=&is_active=
```

**Permission:** Project read

| Query | Description |
| - | - |
| `page`, `limit`, `offset` | Pagination (see [envelopes](#response-envelopes)) |
| `is_active` | `true` or `false` to filter by activation |

```bash theme={null}
curl -sS \
  -H "Authorization: Bearer $TOKEN" \
  -H "ORG-ID: $ORG_ID" \
  "$API/projects/$PROJECT_ID/ticket-workflows?is_active=true&limit=50"
```

**Response:** `data.items[]` of [workflow version items](#workflow-version-item) plus `data.meta`.

### Get workflow version

```
GET /core/api/v1/projects/{project_id}/ticket-workflows/{workflow_id}
```

**Permission:** Project read

`workflow_id` is a **version** id.

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

**Response:** `data.item` — a [workflow version item](#workflow-version-item).

### List state labels

```
GET /core/api/v1/projects/{project_id}/ticket-workflows/{workflow_id}/state-labels
```

**Permission:** Project read

Returns the state labels of one workflow version, sorted by label key. Use the `label` value as `to_label` when you transition.

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

**Response:** `data.items[]` of [state label items](#state-label-item). Not paginated.

### Validate a workflow

```
POST /core/api/v1/projects/{project_id}/ticket-workflows/validate
```

**Permission:** Project member

Dry-runs validation. Nothing is saved.

**Request body:** `{"workflow": {…}}` — same as [create](#create-workflow).

```bash JSON theme={null}
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"
```

```bash YAML theme={null}
curl -sS -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "ORG-ID: $ORG_ID" \
  -H "Content-Type: application/yaml" \
  --data-binary @maintenance-request.yaml \
  "$API/projects/$PROJECT_ID/ticket-workflows/validate"
```

**Response `data` fields:**

| Field | Type | Description |
| - | - | - |
| `valid` | boolean | `true` when there are no errors |
| `errors` | array | `{ path, message }`, e.g. `path: "transitions[0].from"` |
| `warnings` | array | `{ path, message }`; omitted when empty |

```json theme={null}
{
  "ok": 1,
  "data": {
    "valid": false,
    "errors": [
      { "path": "transitions[0].from", "message": "…" }
    ]
  }
}
```

A body that does not parse, or a spec missing `name` or `initial`, returns **400** `invalid workflow` instead of a report.

### Create workflow

```
POST /core/api/v1/projects/{project_id}/ticket-workflows
```

**Permission:** Project **manage workflows**

**Request body:** `{"workflow": {…}}`. Top-level spec fields:

| Field | Required | Description |
| - | - | - |
| `name` | Yes | Workflow name; reusing a name adds a version to the same definition |
| `initial` | Yes | Label key new tickets start in |
| `kind` | No | Workflow kind, e.g. `work_order` |
| `labels` | Yes | `{ import: [keys], extra: [{ key, class, color?, terminal? }] }` |
| `transitions` | Yes | `[{ from, to, permit[], guards[{cel}], on_enter[], on_exit[] }]` — every transition needs at least one `permit` |
| `forms` | No | `{ import: [{ name, version? }], extra: [form specs] }` |
| `validation` | No | `on_create` and `before_transition` rules |
| `trigger_intake` | No | Event-driven ticket creation |
| `due_policy` | No | `{ lead_time, on_breach: { to } }` |

Full field reference: [Workflows](/ticketing/workflows) and [Automation](/ticketing/automation).

`maintenance-request.json`:

```json theme={null}
{
  "workflow": {
    "name": "maintenance-request",
    "initial": "new",
    "labels": {
      "extra": [
        { "key": "new", "class": "open" },
        { "key": "in_progress", "class": "in_progress" },
        { "key": "resolved", "class": "terminal_success", "terminal": true },
        { "key": "cancelled", "class": "cancelled", "terminal": true }
      ]
    },
    "transitions": [
      { "from": "new", "to": "in_progress", "permit": ["project.member"] },
      { "from": "in_progress", "to": "resolved", "permit": ["ticket.assignee", "project.member"] },
      { "from": "new", "to": "cancelled", "permit": ["project.member"] },
      { "from": "in_progress", "to": "cancelled", "permit": ["project.member"] }
    ]
  }
}
```

```bash theme={null}
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"
```

**Response:**

```json theme={null}
{
  "ok": 1,
  "data": {
    "item": {
      "id": "b7e0c9d4-2f3a-4c61-8e7d-5a9f0c1b2d34",
      "workflow_def_id": "0d8f4e2a-7c1b-4a9e-b3f6-2e5d8c7a1f90",
      "workflow_name": "maintenance-request",
      "version": 1,
      "is_active": false,
      "spec": { "name": "maintenance-request", "initial": "new", "…": "…" },
      "validated_at": "2026-10-05T09:15:02.481Z",
      "validation_report": { "ok": true, "errors": null },
      "created_by": "user-subject-id",
      "created_at": "2026-10-05T09:15:02.481Z"
    }
  }
}
```

> [!WARNING]
>
> Create saves the version even when validation fails. Check `validation_report.ok` — a version with `ok: false` cannot be activated. Call [validate](#validate-a-workflow) first to see structured errors.

### Update workflow

```
PATCH /core/api/v1/projects/{project_id}/ticket-workflows/{workflow_id}
```

**Permission:** Project **manage workflows**

Writes a **new, inactive** version on the same definition. `workflow_id` is any existing version id. `workflow.name` must match the definition (**400** `workflow yaml name must match existing workflow`).

**Request body:** `{"workflow": {…}}` — full spec, as in create.

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

**Response:** `data.item` — the new [workflow version item](#workflow-version-item).

### Activate workflow version

```
POST /core/api/v1/projects/{project_id}/ticket-workflows/{workflow_id}/activate
```

**Permission:** Project **manage workflows**

Makes this version the active one for its definition and deactivates the previous one. Existing tickets keep the version they were created on.

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

**Response:** `data.item` with `is_active: true`. **400** `workflow validation failed` if the version's `validation_report.ok` is `false`.

### Delete workflow version

```
DELETE /core/api/v1/projects/{project_id}/ticket-workflows/{workflow_id}
```

**Permission:** Project **manage workflows**

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

**Response:** `{"ok": 1}`

Returns **409** when the version is active (`cannot delete active workflow version`), when tickets use it (`workflow version is referenced by tickets`), or when it has pending actions (`workflow version has pending actions`).

***

## Workflow templates

Templates are platform-provided workflows (with their forms) that you install into a Project as your own editable copy. The catalog is the same for every Project.

### List templates

```
GET /core/api/v1/projects/{project_id}/ticket-workflow-templates
```

**Permission:** Project read

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

**Response:** `data.items[]`, not paginated:

| Field | Description |
| - | - |
| `key` | Template key, e.g. `cmms.work_order` |
| `name` | Workflow name the install creates |
| `kind` | Workflow kind, e.g. `work_order` |
| `revision` | Template revision |
| `description` | Text or `null` |
| `is_active` | Whether the template is offered |
| `created_at`, `updated_at` | Timestamps |
| `forms` | `[{ key, name, version, revision, ordinal }]` — forms installed with it |

### Instantiate template

```
POST /core/api/v1/projects/{project_id}/ticket-workflow-templates/{template_key}/instantiate
```

**Permission:** Project **manage assets**

Installs the template's forms and workflow into this Project and activates the workflow. Calling it again is safe: it returns `already_installed: true` and re-creates any forms you deleted.

```bash theme={null}
curl -sS -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "ORG-ID: $ORG_ID" \
  -H "Idempotency-Key: $(uuidgen)" \
  "$API/projects/$PROJECT_ID/ticket-workflow-templates/cmms.work_order/instantiate"
```

**Response:**

```json theme={null}
{
  "ok": 1,
  "data": {
    "item": {
      "workflow_def_id": "…",
      "workflow_version_id": "…",
      "version": 1,
      "form_ids": ["…", "…"],
      "already_installed": false
    }
  }
}
```

**404** for an unknown `template_key`. **409** when a workflow or form you authored already uses the template's name.

***

## Tickets

### List tickets

```
GET /core/api/v1/projects/{project_id}/tickets
```

**Permission:** Project read

Results are sorted newest first.

| Query | Description |
| - | - |
| `page`, `limit`, `offset` | Pagination — `limit` default `10`, max `200` |
| `ids` | Comma-separated ticket UUIDs |
| `workflow_version_id` | Workflow version UUID |
| `state_label_id` | State label UUID |
| `labels` | Comma-separated label keys. Matching ignores case and non-alphanumeric characters, so `in_progress`, `In Progress`, and `in-progress` match the same label |
| `system_class` | `open`, `in_progress`, `terminal_success`, `terminal_failed`, or `cancelled` |
| `assigned_group_id` | User group UUID |
| `assigned_to` | User id |
| `title_search` | Case-insensitive substring of the title. Ignored when shorter than 3 characters |
| `tag_kind` | Tickets with at least one tag of this kind |
| `tag_value` | With `tag_kind`, require this exact tag value |
| `fields` | Comma-separated projection (see below) |

`fields` accepts: `id`, `org_id`, `project_id`, `workflow_version_id`, `state_label_id`, `system_class`, `title`, `body`, `assigned_to`, `assigned_group_id`, `opened_at`, `closed_at`, `due_at`, `metadata`, `created_by`, `created_at`, `updated_at`, `number`, `tags`, `state_label`. An unknown name returns **400** `unknown field: …`.

`tags` and `state_label` are only available through `fields`:

* `tags` — `[{ id, ticket_id, kind, value, label, metadata, created_at }]`, oldest first
* `state_label` — a [state label item](#state-label-item)

```bash theme={null}
curl -sS \
  -H "Authorization: Bearer $TOKEN" \
  -H "ORG-ID: $ORG_ID" \
  "$API/projects/$PROJECT_ID/tickets?labels=new,in_progress&fields=id,number,title,metadata,tags,state_label&limit=50"
```

**Response:**

```json theme={null}
{
  "ok": 1,
  "data": {
    "items": [
      {
        "id": "3c9a5e71-0b2d-4f8e-a6c4-91d7e2f0b5a8",
        "number": 42,
        "title": "Conveyor 3 belt slipping",
        "metadata": { "location": "Line 3" },
        "tags": [
          { "id": "…", "ticket_id": "…", "kind": "priority", "value": "high", "label": "High", "metadata": {}, "created_at": "…" }
        ],
        "state_label": { "id": "…", "label": "new", "system_class": "open", "is_initial": true, "is_terminal": false, "…": "…" }
      }
    ],
    "meta": { "currentPage": 1, "itemsPerPage": 50, "totalItems": 1, "totalPages": 1 }
  }
}
```

> [!WARNING]
>
> Without `fields`, list items are full [Ticket](#ticket) objects and `metadata` is a **base64-encoded JSON string**. With `fields` that include `metadata`, it is a decoded JSON object. Single-ticket responses (get, create, patch) also return base64 `metadata`.

### Get ticket

```
GET /core/api/v1/projects/{project_id}/tickets/{ticket_id}
```

**Permission:** Project read

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

**Response:** `data.item` — a [Ticket](#ticket). **404** `ticket not found`.

### Create ticket

```
POST /core/api/v1/projects/{project_id}/tickets
```

**Permission:** Project member

**Request body:**

| Field | Type | Required | Description |
| - | - | - | - |
| `workflow_def_id` | string | Yes | Workflow **definition** id; the ticket uses its active version |
| `title` | string | Yes | Ticket title |
| `body` | string | No | Description |
| `initial_tags` | array | No | `[{ kind, value, label? }]` |
| `metadata` | object | No | Free-form JSON |
| `form_payload` | object | No | Answers for the workflow's `on_create` form |
| `due_at` | string | No | RFC 3339 timestamp; drives `due_policy` timers |
| `assigned_user_id` | string | No | Assignee user id (must be a Project member) |
| `assigned_group_id` | string | No | Assignee user group UUID |

Set at most one of `assigned_user_id` and `assigned_group_id`. Unknown body fields return **400**.

```bash theme={null}
curl -sS -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "ORG-ID: $ORG_ID" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "workflow_def_id": "0d8f4e2a-7c1b-4a9e-b3f6-2e5d8c7a1f90",
    "title": "Conveyor 3 belt slipping",
    "body": "Belt slips under load on the morning shift.",
    "initial_tags": [{ "kind": "priority", "value": "high", "label": "High" }],
    "metadata": { "location": "Line 3" },
    "due_at": "2026-10-07T17:00:00Z"
  }' \
  "$API/projects/$PROJECT_ID/tickets"
```

**Response:**

```json theme={null}
{
  "ok": 1,
  "data": {
    "item": {
      "id": "3c9a5e71-0b2d-4f8e-a6c4-91d7e2f0b5a8",
      "org_id": "…",
      "project_id": "…",
      "workflow_version_id": "b7e0c9d4-2f3a-4c61-8e7d-5a9f0c1b2d34",
      "state_label_id": "…",
      "system_class": "open",
      "number": 42,
      "title": "Conveyor 3 belt slipping",
      "body": "Belt slips under load on the morning shift.",
      "assigned_to": null,
      "assigned_group_id": null,
      "opened_at": "2026-10-05T09:20:11.002Z",
      "closed_at": null,
      "due_at": "2026-10-07T17:00:00Z",
      "metadata": "eyJsb2NhdGlvbiI6IkxpbmUgMyJ9",
      "created_by": "user-subject-id",
      "created_at": "2026-10-05T09:20:11.002Z",
      "updated_at": "2026-10-05T09:20:11.002Z"
    }
  }
}
```

Common failures:

| Status | Message | Cause |
| - | - | - |
| 404 | `active workflow not found` | No active version for `workflow_def_id` |
| 400 | `on_create requires tag kind "…"` | `validation.on_create.require_tag_kinds` not met |
| 400 | `on_create requires form payload` | Workflow requires a form and `form_payload` is missing |
| 400 | `invalid due_at` | Not RFC 3339 |
| 400 | `unknown tag kind "…"` / `device not found in this project` | Tag kind or referenced entity is invalid |

### Update ticket

```
PATCH /core/api/v1/projects/{project_id}/tickets/{ticket_id}
```

**Permission:** Project member

**Request body** (at least one field):

| Field | Type | Description |
| - | - | - |
| `title` | string | New title |
| `body` | string | New description |
| `due_at` | string | RFC 3339 timestamp |
| `metadata` | object | Metadata object |

State, assignment, and tags have their own routes. Unknown fields return **400**.

```bash theme={null}
curl -sS -X PATCH \
  -H "Authorization: Bearer $TOKEN" \
  -H "ORG-ID: $ORG_ID" \
  -H "Content-Type: application/json" \
  -d '{"due_at":"2026-10-08T12:00:00Z","metadata":{"location":"Line 3","shift":"morning"}}' \
  "$API/projects/$PROJECT_ID/tickets/$TICKET_ID"
```

**Response:** `data.item` — the updated [Ticket](#ticket).

### Delete ticket

```
DELETE /core/api/v1/projects/{project_id}/tickets/{ticket_id}
```

**Permission:** Project member

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

**Response:** `{"ok": 1}`

***

## Transitions

### Transition a ticket

```
POST /core/api/v1/projects/{project_id}/tickets/{ticket_id}/transition
```

**Permission:** Project member, plus a matching `permit` on the transition

**Request body:**

| Field | Type | Required | Description |
| - | - | - | - |
| `to_label` | string | Yes | Target label **key**, e.g. `in_progress` (not the label id) |
| `reason` | string | No | Stored on the transition record |
| `form_payload` | object | No | Answers for a `before_transition` form |

The workflow must declare a transition from the current label to `to_label`.

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

**Response:**

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

Re-read the ticket to see the new `state_label_id` and `system_class`. Moving to a terminal label sets `closed_at`.

| Status | Message | Cause |
| - | - | - |
| 400 | `no such transition` | No transition from the current label to `to_label` |
| 400 | `unknown target state` | `to_label` is not a label in the ticket's workflow version |
| 400 | `guard failed` | A guard CEL expression returned `false` |
| 400 | `missing required tag kind "…" before transition` | `before_transition.require_tag_kinds` not met |
| 400 | `transition … requires form payload` | `before_transition.require_form` and no `form_payload` |
| 403 | `transition permit denied` | Caller matches none of the transition's permits |
| 409 | `cannot transition to terminal: N open subticket(s) remain` | Ticket has open child tickets linked with `subticket` tags |
| 409 | `ticket state changed concurrently` | Another transition landed first; re-read and retry |

> [!NOTE]
>
> The sub-ticket check only counts children linked by a `subticket` tag on the parent (value = child ticket id). The Console links sub-tickets through `metadata.parent_ticket_id`, which this check does not read.

### List transition history

```
GET /core/api/v1/projects/{project_id}/tickets/{ticket_id}/transitions?page=&limit=
```

**Permission:** Project read

Newest first.

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

**Response:** `data.items[]` of [transition records](#transition-record) plus `data.meta`.

```json theme={null}
{
  "ok": 1,
  "data": {
    "items": [
      {
        "id": "…",
        "ticket_id": "3c9a5e71-0b2d-4f8e-a6c4-91d7e2f0b5a8",
        "from_label": "new",
        "from_label_id": "…",
        "to_label": "in_progress",
        "to_label_id": "…",
        "actor_kind": "user",
        "actor_id": "user-subject-id",
        "reason": "Technician on site",
        "payload": null,
        "occurred_at": "2026-10-05T09:31:47.550Z"
      }
    ],
    "meta": { "currentPage": 1, "itemsPerPage": 50, "totalItems": 1, "totalPages": 1 }
  }
}
```

***

## Assignment

A ticket has one user assignee (`assigned_to`) or one group assignee (`assigned_group_id`). Assigning a group clears `assigned_to`.

### Assign a ticket

```
POST /core/api/v1/projects/{project_id}/tickets/{ticket_id}/assign
```

**Permission:** Project member

**Request body** (exactly one):

| Field | Type | Description |
| - | - | - |
| `assigned_user_id` | string | User id; must be a Project member |
| `assigned_group_id` | string | User group UUID in this Organization |

```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":"user-subject-id"}' \
  "$API/projects/$PROJECT_ID/tickets/$TICKET_ID/assign"
```

**Response:** `{"ok": 1, "data": {"ok": true}}`

**400** when both or neither field is set, when the user is not a Project member, or when the group is not in this Organization.

### Unassign a ticket

```
POST /core/api/v1/projects/{project_id}/tickets/{ticket_id}/unassign
```

**Permission:** Project member

**Request body:**

| Field | Type | Required | Description |
| - | - | - | - |
| `target` | string | Yes | `user` or `group` |

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

**Response:** `{"ok": 1, "data": {"ok": true}}`

→ Notifications for assignment changes: [Assignment and notifications](/ticketing/assignment-notifications)

***

## Tags

Tags link a ticket to entities (`device`, `asset`, `user`, `subticket`, …) or carry scalar values (`priority`, `severity`, …). Entity tags must reference something that exists in the Project; a `user` tag must be a Project member. Read tags with `GET …/tickets?fields=id,tags`.

### Add a tag

```
POST /core/api/v1/projects/{project_id}/tickets/{ticket_id}/tags
```

**Permission:** Project member

**Request body:**

| Field | Type | Required | Description |
| - | - | - | - |
| `kind` | string | Yes | Tag kind |
| `value` | string | Yes | Tag value (entity id for entity kinds) |
| `label` | string | No | Display label |

```bash theme={null}
curl -sS -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "ORG-ID: $ORG_ID" \
  -H "Content-Type: application/json" \
  -d '{"kind":"device","value":"'"$DEVICE_ID"'","label":"Conveyor 3 PLC"}' \
  "$API/projects/$PROJECT_ID/tickets/$TICKET_ID/tags"
```

**Response:**

```json theme={null}
{ "ok": 1, "data": { "tagId": "9a2e7c41-5d0b-4b6f-8e13-c4f7a0d29b85" } }
```

**400** for an unknown kind or a value that fails validation. **404** if the ticket does not exist.

### Remove a tag

```
DELETE /core/api/v1/projects/{project_id}/tickets/{ticket_id}/tags/{tag_id}
```

**Permission:** Project member

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

**Response:** `{"ok": 1}`. **404** `ticket tag` if the tag is not on this ticket.

***

## Object reference

### Ticket

| Field | Type | Description |
| - | - | - |
| `id` | string | Ticket UUID |
| `number` | number \| null | Per-Project ticket number |
| `org_id`, `project_id` | string | Scope |
| `workflow_version_id` | string | Workflow version the ticket runs on |
| `state_label_id` | string | Current state label id |
| `system_class` | string | `open`, `in_progress`, `terminal_success`, `terminal_failed`, `cancelled` |
| `title` | string | Title |
| `body` | string \| null | Description |
| `assigned_to` | string \| null | Assignee user id |
| `assigned_group_id` | string \| null | Assignee group UUID |
| `opened_at` | string | When the ticket opened |
| `closed_at` | string \| null | Set on entering a terminal label |
| `due_at` | string \| null | Due timestamp; drives `due_policy` |
| `metadata` | string \| object | Base64 JSON string, or an object when projected with `fields` |
| `created_by` | string \| null | Creator user id |
| `created_at`, `updated_at` | string | Timestamps |

> [!NOTE]
>
> The Console's **Due date** field writes `metadata.due_at`, not the top-level `due_at`. Only the top-level `due_at` drives `due_policy` timers.

### Workflow version item

| Field | Type | Description |
| - | - | - |
| `id` | string | Version id (use as `workflow_id` in paths) |
| `workflow_def_id` | string | Definition id (use as `workflow_def_id` when creating tickets) |
| `workflow_name` | string | Definition name |
| `version` | number | Version number, starting at `1` |
| `spec` | object | The stored workflow spec |
| `is_active` | boolean | Whether this is the active version |
| `validated_at` | string | When validation ran |
| `validation_report` | object | `{ ok, errors[], warnings[] }` with string messages |
| `created_by` | string \| null | Author user id |
| `created_at` | string | Timestamp |

### Form item

| Field | Type | Description |
| - | - | - |
| `id` | string | Form version id |
| `org_id`, `project_id` | string | Scope |
| `name` | string | Form name |
| `version` | number | Version number |
| `spec` | object | `{ name, schema, bindings }` |
| `validated_at` | string | When validation ran |
| `validation_report` | object | `{ ok, errors }` |
| `created_by` | string \| null | Author user id |
| `created_at` | string | Timestamp |

### State label item

| Field | Type | Description |
| - | - | - |
| `id` | string | State label id |
| `org_id`, `project_id` | string | Scope |
| `workflow_version_id` | string | Owning workflow version |
| `project_state_label_id` | string \| null | Set when the label was imported from the Project label catalog |
| `label` | string | Label key (use as `to_label`) |
| `system_class` | string | System class |
| `color` | string \| null | Display color |
| `is_initial` | boolean | Ticket start label |
| `is_terminal` | boolean | Ends the ticket |
| `created_at`, `updated_at` | string | Timestamps |

### Transition record

| Field | Type | Description |
| - | - | - |
| `id` | string | Record id |
| `ticket_id` | string | Ticket id |
| `from_label`, `from_label_id` | string \| null | Previous label; `from_label` is `null` and `from_label_id` is omitted when there is no previous label |
| `to_label`, `to_label_id` | string | New label |
| `actor_kind` | string | `user` for API calls; automated moves record a different kind |
| `actor_id` | string \| null | Actor id |
| `reason` | string \| null | Reason from the request |
| `payload` | object \| null | Extra data recorded with the move |
| `occurred_at` | string | Timestamp |

***

## Error codes

| Status | When it occurs |
| - | - |
| `400` | Invalid or unknown body fields, bad UUIDs or query values, unknown `fields` name, failed form or tag validation, missing required tags or forms, guard failed, no such transition, activating an invalid workflow version |
| `401` | Missing or invalid access token |
| `403` | Missing Project permission (member, manage workflows, manage assets), or no matching transition `permit` |
| `404` | Ticket, form, workflow, active workflow version, tag, or template not found in this Project |
| `409` | Retry of an `Idempotency-Key` request still in progress; concurrent state change; open sub-tickets block a terminal move; deleting an active or in-use workflow version; template name collision |
| `429` | Rate limit exceeded — back off and retry |
| `504` | The request timed out upstream. The change may still have been applied — re-read before retrying, or retry with the same `Idempotency-Key` |

## Related

* [Ticketing overview](/ticketing/overview) — concepts and lifecycle
* [Create your first ticket](/ticketing/first-ticket) — end-to-end walkthrough
* [Workflows](/ticketing/workflows) — labels, transitions, permits, guards
* [Forms](/ticketing/forms) — JSON Schema forms and bindings
* [Automation](/ticketing/automation) — actions, trigger intake, due policy, [alarm](/alarms/overview) work orders
* [Troubleshooting](/ticketing/troubleshooting) — when calls fail


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