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

> Diagnose workflow validation errors, rejected ticket creates and transitions, automation that did not run, due-date policy, notifications, and list filters.

Each entry below follows the same shape: the **symptom** you see, the **cause**, and the **fix**. Error text in quotes is the exact `message` the API returns.

Set these before you run the commands on this page:

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

Errors use this envelope. `message` is the short reason. `detail` sometimes carries more context.

```json theme={null}
{ "ok": 0, "message": "no such transition", "detail": "…" }
```

## Quick checks

Run these first. They answer most "why did this happen" questions.

**1. Check the workflow version and its validation report**

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

Look at `is_active` and `validation_report`. A version with `validation_report.ok: false` cannot be activated. Its `errors` list each problem as `"path: message"`.

**2. Check which version the ticket is on**

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

Compare `workflow_version_id` with the version you edited. Tickets stay on the version that was active when they were created.

**3. Read the transition history**

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

Each row shows `from_label`, `to_label`, `actor_kind` (`user` or `system`), `actor_id`, `reason`, and `occurred_at`. A due-date breach appears as `actor_kind: "system"` with `reason: "due policy breach"`.

## Symptom index

| Symptom | Likely cause | Section |
| - | - | - |
| Activate returns `400` "workflow validation failed" | Spec has graph, permit, CEL, or action errors | [Workflow validation](#workflow-validation-and-activation) |
| Delete workflow returns `409` | Version is active, used by tickets, or has pending actions | [Deleting workflows](#deleting-workflows) |
| Create ticket returns `404` "active workflow not found" | Wrong ID or no active version | [Ticket create](#ticket-create-rejected) |
| Create ticket returns `400` | Missing tag kind, form payload, bad `due_at`, two assignees | [Ticket create](#ticket-create-rejected) |
| Transition returns `400` "no such transition" | No edge from the current label, or wrong `to_label` | [Transitions](#transition-rejected) |
| Transition returns `403` "transition permit denied" | Caller matches no `permit` entry | [Transitions](#transition-rejected) |
| Transition returns `409` "cannot transition to terminal…" | Open sub-tickets | [Transitions](#transition-rejected) |
| Edited workflow, old tickets behave the old way | Tickets are pinned to their version | [Versions](#workflow-edits-not-applied-to-existing-tickets) |
| Webhook or device call did not happen | Action failed and exhausted retries | [Actions](#transition-actions) |
| Event-driven tickets never appear | Version inactive, filter false, cooldown, grouping | [Trigger intake](#trigger-intake) |
| Due date passed, ticket did not move | No `due_policy`, no eligible edge, or `due_at` not set | [Due dates](#due-date-and-breach) |
| Nobody received a notification | Subscription filters, `notify_actor`, suppressed deliveries | [Notifications](#notifications) |
| `429` "rate limit exceeded" | Too many requests per second for that route | [Rate limits](#rate-limits) |
| List returns nothing or ignores a filter | Filter semantics | [Listing tickets](#listing-and-filtering) |

## Workflow validation and activation

Creating or patching a workflow always stores a new version, even when the spec is invalid. The problems are recorded in `validation_report`. Activation is where an invalid spec is refused. Run **validate** first to see every error without storing anything:

```bash theme={null}
curl -sS -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "ORG-ID: $ORG_ID" \
  -H "Content-Type: application/json" \
  "$API/projects/$PROJECT_ID/ticket-workflows/validate" \
  -d '{
    "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"] }
      ]
    }
  }'
```

`/validate` returns `200` whether or not the spec is valid. Read `data.valid` and `data.errors`:

```json theme={null}
{
  "ok": 1,
  "data": {
    "valid": false,
    "errors": [
      { "path": "labels", "message": "G1: label \"cancelled\" is not reachable from initial \"new\"" }
    ]
  }
}
```

> **Graph errors: G1, G2, G4**
>
> | Message | Cause | Fix |
> | - | - | - |
> | `G1: label "X" is not reachable from initial "Y"` | No chain of transitions leads from `initial` to `X` | Add a transition into `X`, or remove the label |
> | `G2: non-terminal label "X" has no outgoing transitions` | Tickets in `X` would be stuck | Add a transition out of `X`, or mark it terminal (`terminal: true` or class `terminal_success`, `terminal_failed`, `cancelled`) |
> | `G4: no path from initial reaches a terminal label` | No ticket could ever close | Add a path to at least one terminal label |
> | `initial state "X" not found in labels` | `initial` names a label you did not declare | Declare it in `labels.extra` or `labels.import` |
> | `unknown from "X"` / `unknown to "X"` | A transition references an undeclared label | Fix the key, or declare the label |
> | `duplicate transition from "X" to "Y"` | Two edges with the same `from` and `to` | Merge them into one edge with several `permit` entries |
> | `unknown class "X"` | Label class is not one of `open`, `in_progress`, `terminal_success`, `terminal_failed`, `cancelled` | Use a supported class |

> **Permit errors**
>
> | Message | Cause | Fix |
> | - | - | - |
> | `at least one permit clause is required` | A transition has an empty `permit` list | Add at least one permit, for example `project.member` |
> | `malformed permit "X"` | Not a reserved permit and not in `<entity>.<permission>` form | Use `system`, `ticket.assignee`, `ticket.tagged_user`, `ticket.creator`, `ticket.assignee_group_member`, or `<entity>.<permission>` |
> | `unknown permit "X"` | Whitespace inside the entity or permission | Remove the spaces |
>
> Validation only checks the permit's shape. A well-formed permit that names an entity or permission that does not exist passes validation but never matches anyone at runtime. See [Workflows](/ticketing/workflows) for the permit reference.

> **Guard CEL errors**
>
> Guards are compiled at validation time. An undeclared variable fails, for example `foo == 1`. A guard must return `bool`. You get `cel: guard expression must evaluate to bool, got …` otherwise.
>
> Guards can read `ticket`, `actor`, `event`, `now`, `queue`, `project`, and `http`. Inside `ticket`, use `id`, `metadata`, `assigned_to`, `assigned_group_id`, `tagged_user_ids`, and `tags`.

> **Action errors (on\_enter / on\_exit)**
>
> | Message | Fix |
> | - | - |
> | `kind is required` / `unsupported action kind "X" (use ticket.http or ticket.rpc)` | Set `kind` to `ticket.http` or `ticket.rpc` |
> | `url is required` / `url: require http or https scheme` | Use an absolute `http` or `https` URL |
> | `url: blocked IP …` / `url: host "X" resolves to blocked IP …` | Private and link-local addresses are refused. Use a publicly reachable host |
> | `url: resolve host: …` | The host must resolve in DNS when you validate |
> | `invalid method "X"` | Use `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, or `OPTIONS` |
> | `binding N: metadata_path required for kind=metadata` | Set `writes_to.metadata_path` |
> | `binding N: writes_to.kind "X" is not implemented yet` | Only `metadata` works for HTTP response bindings |
> | `device_id is required` / `service_name is required` / `method_name is required` | Fill the `ticket.rpc` fields. `device_id` and each `fields` value are CEL expressions |
> | `ttl_seconds must be between 60 and 86400 when set` | Omit `ttl_seconds`, or keep it in range |

> **Validation rule and due policy errors**
>
> | Message | Fix |
> | - | - |
> | `no declared transition from "X" to "Y"; this before_transition rule (e.g. require_form) would never fire` | Each `validation.before_transition` rule must match a declared edge |
> | `require_form and optional_form are mutually exclusive…` | Use only one of them under `validation.on_create` |
> | `no declared transition targets "X"; this on_breach policy could never fire` | Add a transition into the `due_policy.on_breach.to` label |
> | `transition A -> B does not permit system; on_breach would fail at runtime with a permit denial` | Add `system` to `permit` on every transition into the breach label |
> | `lead_time must be > 0` / `invalid duration "X"` | Use a positive duration such as `24h` or `30m` |

> **Trigger intake errors (T1–T12)**
>
> | Message | Fix |
> | - | - |
> | `T1: unsupported trigger intake event "X"…` | Use one of the supported events listed on [Automation](/ticketing/automation) |
> | `T2: title is required` / `T2: title CEL compile error: …` | Every intake needs a CEL `title`, for example `'"Device created: " + event.payload.device_id'` |
> | `T3: CEL compile error: …` | Fix `filter`, `dedupe_key`, a `metadata` value, or a `tags[].value`. Intake CEL can read `event`, `project`, and `group` |
> | `T4: cooldown must be at least 1s` | Use `0s` for no dedup, or `1s` and up |
> | `T6: event "X" appears more than once…` | Each event may appear once per workflow |
> | `T7: metadata key "X" must not contain dots or whitespace` | Rename the key |
> | `T8: workflows with trigger_intake must declare initial label "X" in labels.extra with class=open` | Move the initial label into `labels.extra` with `class: open` |
> | `T12: group_wait_mode must be "fixed" or "debounce"` | Use one of the two modes |
>
> `T11: group_by and dedupe_key are both set; group_by takes precedence at runtime` is a warning, not an error. The `dedupe_key` is ignored.

> **A field I set seems to be ignored**
>
> Unknown fields in a workflow spec are dropped silently, so a typo such as `permits` instead of `permit` disappears without an error. Re-read the stored `spec` from `GET …/ticket-workflows/{workflow_id}` and compare it with what you sent.

> **Validate returns 400 "workflow is required" or "invalid workflow"**
>
> * **"workflow is required"**: The body has no `workflow` key. This also applies to YAML bodies. Send `Content-Type: application/yaml` and put the spec under a top-level `workflow:` key.
> * **"invalid workflow"**: The spec could not be parsed, or `name` or `initial` is missing (`detail` says `workflow name and initial are required`).

> **PATCH returns 400 "workflow yaml name must match existing workflow"**
>
> A patch adds a version to an existing workflow, so the `name` in the body must match it. To start a separate workflow, `POST` a new one with a different name.
>
> `POST /ticket-workflows` with a name that already exists in the Project does not fail. It adds a new version to that workflow.

> **Template instantiate returns 409**
>
> "A workflow or form with this template's name already exists in the project". You already have a workflow or form with the template's name, and it was not created from this template. Rename or remove the existing one, then retry. Instantiating needs the **manage assets** permission.

## Deleting workflows

`DELETE …/ticket-workflows/{workflow_id}` deletes one **version**, not the whole definition.

| `409` message | Cause | Fix |
| - | - | - |
| "cannot delete active workflow version" | The version is active | Activate a different version first |
| "workflow version is referenced by tickets" | At least one ticket is pinned to this version | Leave it in place. Tickets keep their version for their whole life |
| "workflow version has pending actions" | `on_enter` / `on_exit` actions are still queued | Wait for the actions to finish or exhaust their retries, then retry |

## Ticket create rejected

> **404 "active workflow not found"**
>
> * You passed a workflow **version** ID instead of the definition ID. `workflow_def_id` comes from the `workflow_def_id` field of a workflow item, not its `id`.
> * The definition has no active version. Activate one with `POST …/ticket-workflows/{workflow_id}/activate`.

> **400 "on\_create requires tag kind …"**
>
> The workflow lists `X` in `validation.on_create.require_tag_kinds`. Add it in `initial_tags`:
>
> ```json theme={null}
> {
>   "workflow_def_id": "8a6f1c2e-3b4d-4e5f-9a7b-1c2d3e4f5a6b",
>   "title": "Chiller 2 making grinding noise",
>   "initial_tags": [
>     { "kind": "device", "value": "d3b07384-d9a0-4c9e-8f3b-2a1c5e6f7a8b" }
>   ]
> }
> ```
>
> A form binding with `writes_to.kind: tag` can also supply the tag.

> **400 "Invalid tag value or reference" and other tag errors**
>
> Each initial tag is checked against its kind. Typical messages:
>
> | Message | Fix |
> | - | - |
> | `unknown tag kind "X"` | Use a tag kind that exists in the platform |
> | `device not found in this project` (also asset, fleet, OTA release, object, ticket, maintenance plan) | The referenced ID must belong to this Project |
> | `user is not a project member in scope` | Tag only users who are members of the Project |
> | `user group not found in this organization` | Check the group ID |
> | `tag value failed schema` | The value does not match the kind's expected format |

> **400 "on\_create requires form payload" or form errors**
>
> The workflow has `validation.on_create.require_form`. Send `form_payload`.
>
> | Message | Cause |
> | - | - |
> | "form payload failed validation" | `form_payload` does not satisfy the form's JSON Schema |
> | `missing form field "X" for form "Y"` | A field used by a binding is missing from `form_payload` |
> | `form "X" not found` / `form "X" vN not found` | The referenced form, or that pinned version, does not exist in this Project |
> | "invalid tag value for form binding" | A field bound to a tag must be a scalar value |
>
> `require_form` without `version` always uses the latest form version. Patching a form can therefore change what new tickets must send. See [Forms](/ticketing/forms).

> **400 "invalid due\_at"**
>
> `due_at` must be RFC3339, for example `2026-10-12T17:00:00Z`. Date-only values such as `2026-10-12` are rejected.

> **400 "exactly one of assigned\_user\_id or assigned\_group\_id is required"**
>
> You set both. A ticket has at most one assignee. Send one of them, or neither.
>
> Related messages: "user is not a project member" (the user must belong to the Project) and "user group not found in this organization".

> **403 on create, patch, or delete**
>
> | Operation | Required permission |
> | - | - |
> | Create, patch, delete, transition, assign, tag tickets | Project membership |
> | Validate a workflow | Project membership |
> | Create, patch, delete, activate workflows and forms | **Manage workflows** on the Project |
> | Instantiate a workflow template | **Manage assets** on the Project |

## Transition rejected

`to_label` is a label **key** such as `in_progress`, not a `state_label_id`.

> **400 "no such transition"**
>
> The ticket's workflow version has no edge from the ticket's **current** label to `to_label`. This includes a misspelled or unknown `to_label`.
>
> 1. Get the ticket and note `workflow_version_id`.
> 2. Read that version's `spec.transitions`. Do not read the newest version.
> 3. Pick a `to` that has a matching `from` for the current label.

> **403 "transition permit denied"**
>
> The caller matches none of the edge's `permit` entries. Check:
>
> | Permit | Matches when |
> | - | - |
> | `system` | Only automated actors, such as a due-date breach. **No API caller can use an edge that permits only `system`** |
> | `ticket.assignee`, `ticket.tagged_user` | The caller is the assigned user **or** is tagged on the ticket with a `user` tag |
> | `ticket.creator` | The caller created the ticket. Tickets from trigger intake have no creator |
> | `ticket.assignee_group_member` | The ticket is assigned to a group and the caller is a member. Fails when the ticket is assigned to a user |
> | `project.<permission>` | The caller has that permission on the Project, for example `project.member` |
> | `<entity>.<permission>`, for example `device.can_invoke_rpc` | The caller has that permission on an entity **tagged on this ticket**. No tag of that kind means no match |
>
> Add a permit to the edge in a new workflow version, or assign or tag the right person on the ticket.

> **400 "guard failed" or "guard expression could not be evaluated"**
>
> * **"guard failed"**: A guard returned `false`. The transition is refused and nothing changes.
> * **"guard expression could not be evaluated"**: The guard errored at runtime, for example by reading a tag or metadata key that is not there, or by taking longer than 50 ms. Use `has()` before reading optional values:
>
> ```text theme={null}
> has(ticket.tags.priority) && "high" in ticket.tags.priority
> ```

> **400 "missing required tag kind … before transition"**
>
> A `validation.before_transition` rule for this `from` → `to` lists `X` in `require_tag_kinds`. Add the tag with `POST …/tickets/{ticket_id}/tags`, then retry.

> **400 "transition A -> B requires form payload"**
>
> A `before_transition` rule for this edge has `require_form`. Send `form_payload` in the transition body:
>
> ```json theme={null}
> {
>   "to_label": "resolved",
>   "reason": "Replaced bearing",
>   "form_payload": { "resolution": "Replaced compressor bearing", "downtime_minutes": 45 }
> }
> ```
>
> Form validation errors are the same as on create.

> **409 "cannot transition to terminal: N open subticket(s) remain"**
>
> The parent has `subticket` tags pointing at child tickets that are still `open` or `in_progress`. Close or cancel the children first.
>
> > [!NOTE]
> >
> > The server only counts children linked by a `subticket` tag on the parent. Sub-tickets created in the Console are linked through `metadata.parent_ticket_id` on the child, which this check does **not** read. A parent whose Console sub-tickets are still open can therefore still close. Add `subticket` tags to the parent if you need the server to enforce it.

> **409 "ticket state changed concurrently" or "in progress"**
>
> * **"ticket state changed concurrently"**: Another transition moved the ticket while yours was running. Get the ticket again and decide from its new state.
> * **"in progress"**: You retried with the same `Idempotency-Key` while the first request was still running. Wait, then retry with the same key to receive the original result.

> **504 Gateway Timeout**
>
> The platform did not answer in time. The change may still have been applied. Get the ticket or its transition history before you retry. Send an `Idempotency-Key` header on creates and transitions so a retry does not apply the change twice.

## Workflow edits not applied to existing tickets

**Symptom:** You patched a workflow and activated the new version, but existing tickets still follow the old transitions, guards, actions, or due policy.

**Cause:** Each ticket is pinned to the `workflow_version_id` that was active when it was created. Transitions, permits, guards, forms, actions, and due policy all come from that version. Activating a new version only affects tickets created afterward.

**Fix:**

* Expect old tickets to finish on the old version. Their transitions are listed in that version's `spec`.
* Patching does not activate. Call `POST …/ticket-workflows/{workflow_id}/activate` with the new version's `id`.
* State label IDs are per version. Filter lists with `labels` (keys) rather than `state_label_id` when tickets span versions. See [Listing and filtering](#listing-and-filtering).

## Transition actions

> **on\_exit webhook sees the ticket already in the new state**
>
> Actions run **after** the transition is committed, not during it. By the time an `on_exit` `ticket.http` call fires, the ticket is already in the target state. Within one transition, `on_exit` actions are picked up before `on_enter` actions.
>
> Do not design an `on_exit` hook that needs to observe the old state. Pass what you need in the request instead, or read it from the transition history.

> **The webhook or device call never happened**
>
> Actions start within a few seconds of the transition. A failed action is retried up to **3 attempts, 15 seconds apart**. After the last attempt the action is dropped from the queue. The transition itself is not rolled back.
>
> A `ticket.http` action counts as failed when:
>
> * the URL is refused (private or link-local address, unresolvable host)
> * the response status is outside `200`–`299`
> * a response binding's `from` path is missing from the response body
>
> A `ticket.rpc` action counts as failed when a CEL expression in `device_id` or `fields` errors, or the call cannot be queued. It succeeds when the call is **queued**, not when the device runs it. Check the device side with [Queued invoke](/edge/device-rpc/queued-invoke).
>
> Action outcomes are not exposed on the ticket API. To see what happened:
>
> * check your endpoint's access logs, or the device's RPC audit
> * check `metadata` on the ticket for values that a response binding should have written
> * make the endpoint idempotent, since an action can be retried after a slow partial success

> **Actions use the wrong URL after I edited the workflow**
>
> Actions come from the ticket's pinned workflow version. Tickets on the old version keep calling the old URL. See [Workflow edits not applied](#workflow-edits-not-applied-to-existing-tickets).

## Trigger intake

Trigger intake failures do not return an error to anyone. The event is skipped and no ticket appears. Work through these causes in order.

> **No tickets are created**
>
> | Check | Detail |
> | - | - |
> | **Version is active** | Only the active version's `trigger_intake` listens. A version you created but did not activate listens to nothing |
> | **Event is supported** | Only the events listed on [Automation](/ticketing/automation) can open tickets |
> | **Event belongs to this Project** | Events are matched to the Project they were raised in |
> | **`filter` returns `true`** | A `false` result skips silently. An expression that errors (for example a missing payload key) also skips. Payload fields are under `event.payload`, for example `event.payload.device_id` |
> | **Cooldown** | With `cooldown` set, a second event with the same `dedupe_key` inside the window is dropped |
> | **Empty `dedupe_key` with a cooldown** | All events share one bucket, so only the first event in each window opens a ticket |
> | **Grouping** | With `group_by`, the ticket is created only after `group_wait` elapses. In `debounce` mode, each new event restarts the wait |
> | **`title` is empty** | A `title` expression that returns `""` creates nothing |
> | **Tags are valid** | A tag the platform rejects (for example a `device` value that is not in the Project) fails the whole create |

> **Duplicate tickets from the same condition**
>
> | Cause | Fix |
> | - | - |
> | `cooldown` empty or `0s` | Every event opens a ticket. Set a `cooldown`, for example `24h` |
> | `dedupe_key` too specific | Keys that include a timestamp or reading never repeat. Key on the thing you care about, for example `'"overheat:" + event.payload.device_id'` |
> | New version activated | Dedup windows are tracked per workflow version, so activating a new version starts fresh windows |
> | Two workflows listen to the same event | Each workflow dedups on its own and opens its own ticket |
> | `group_by` and `dedupe_key` both set | `dedupe_key` is ignored. Each group window opens one ticket |
> | Cooldown expired while the first ticket is still open | Cooldown is time-based. It does not check whether an earlier ticket is still open. Use a longer `cooldown` |

> **Alarm did not open a work order**
>
> Alarm-to-work-order creation is set on the **alarm definition**, not in `trigger_intake`. Check that:
>
> * the alarm definition has `auto_create_wo` enabled
> * the alarm instance is attached to an asset
> * `wo_workflow_def_id` points at a workflow with an **active** version. If you leave it empty, the Project needs an active workflow with `kind: work_order`
> * the cooldown has passed. `wo_cooldown` defaults to `24h`, and the default dedup key is per asset
>
> If no workflow resolves, the alarm still fires and no ticket is created. See [Alarms](/alarms/overview).

## Due date and breach

> **The due date passed and the ticket did not move**
>
> A breach moves a ticket only when **all** of these hold:
>
> 1. The ticket's workflow version has a `due_policy` with `on_breach.to`.
> 2. The ticket has a top-level `due_at`, set on create or with `PATCH …/tickets/{ticket_id}`.
> 3. A transition exists from the ticket's **current** label to `on_breach.to`, with `system` in its `permit`.
> 4. That edge has no `before_transition` rule with `require_form`, because a timer cannot fill a form.
> 5. The edge's guards pass. If the target is terminal, the ticket has no open `subticket` children.
> 6. The ticket is not already in a terminal state.
>
> When the move is skipped, the **Ticket Due** notification still fires.
>
> > [!WARNING]
> >
> > The Console's **Due date** field stores `metadata.due_at`. Only the top-level `due_at` field drives due-date timers, warnings, and breaches. Set `due_at` through the API when you rely on `due_policy`.

> **No "Ticket Due Soon" or "Ticket Due" notifications**
>
> * Without a `due_policy` in the ticket's workflow version, no due-date timers are registered, even when `due_at` is set.
> * **Ticket Due Soon** needs `due_policy.lead_time`. It is skipped when `due_at` minus `lead_time` is already in the past.
> * Moving a ticket to a terminal state cancels its timers.

> **"Ticket Due" fires right after the ticket is created**
>
> A `due_at` in the past, or very close to now, fires the breach almost immediately. This is expected.

## Notifications

Start with the delivery log for the subscription. Every person a role selects is either sent a message or listed with a reason.

```bash theme={null}
curl -sS \
  -H "Authorization: Bearer $TOKEN" \
  -H "ORG-ID: $ORG_ID" \
  "$API/notification-subscriptions/$SUBSCRIPTION_ID/deliveries"
```

| Symptom | Cause | Fix |
| - | - | - |
| No delivery rows at all | The event is not on the subscription, or `workflow_def_ids` excludes this ticket's workflow | Add the event, for example `ticket.ticket.transitioned.v1`. Add the workflow definition ID, or `PATCH` `workflow_def_ids: []` to receive every workflow |
| You did not get mail for your own change | `notify_actor` defaults to `false` | Set `notify_actor: true` if people should hear about their own changes |
| Group-assigned tickets notify nobody | `group_assignee_mode: "none"` | Use `"members"` to notify each member |
| Rows with status `suppressed` | The recipient resolved to nobody | Read the reason: group over the size cap, empty or deleted group, no email on record, no read access to the Project, the actor with `notify_actor` off, or already on the fixed `to` list |
| `400` on create or patch | A `workflow_def_ids` entry is not a ticket workflow in scope, or the event has no `recipient_roles` | Check IDs and `GET $API/notification-event-registry` |
| `400` on test send | The subscription uses roles only | Test sends need a fixed `channel_ref.to` |

Project subscriptions live under `$API/projects/$PROJECT_ID/notification-subscriptions` and need **manage integrations** on the Project. Organization subscriptions need **manage integrations** on the Organization. See [Assignment and notifications](/ticketing/assignment-notifications).

## Rate limits

`429` "rate limit exceeded" means you sent too many requests per second to one route. The limit is shared by every caller in your Organization (keyed on `ORG-ID`).

| Route type | Requests per second |
| - | - |
| Reads (list, get, transitions history, state labels) | 64 |
| Creates (tickets, workflows, forms, validate) | 12 |
| Updates, deletes, transitions, assign, tags, activate | 32 |

Back off and retry. For bulk work, pace creates to stay under 12 per second.

## Listing and filtering

| Symptom | Cause | Fix |
| - | - | - |
| `title_search` seems ignored | Values shorter than 3 characters are ignored, so you get unfiltered results | Send at least 3 characters. Matching is a case-insensitive substring |
| `state_label_id` misses some tickets | State label IDs differ per workflow version | Use `labels=in_progress,new` (label keys). Keys match case-insensitively and ignore `_`, `-`, and spaces |
| `labels=` returns nothing | Every value was punctuation only | Send real label keys |
| `400` "unknown field: X" | `fields` contains a name that is not allowed | Use names from the [API reference](/ticketing/api-reference) |
| `400` "invalid system\_class" | Value is not `open`, `in_progress`, `terminal_success`, `terminal_failed`, or `cancelled` | Fix the value |
| `400` "invalid ids…" or "invalid workflow\_version\_id" | A UUID filter is malformed | Send valid UUIDs, comma-separated for `ids` |
| `tag_value` returns nothing | `tag_value` is an exact match and needs `tag_kind` | Send both, with the exact value |
| `metadata` is a long string, not an object | Single-ticket responses and lists without `fields` return `metadata` as base64-encoded JSON | Decode it, or list with `fields=id,title,metadata` to get an object |

Decode `metadata` from a single ticket:

```bash theme={null}
curl -sS \
  -H "Authorization: Bearer $TOKEN" \
  -H "ORG-ID: $ORG_ID" \
  "$API/projects/$PROJECT_ID/tickets/$TICKET_ID" \
  | jq -r '.data.item.metadata' | base64 --decode | jq
```

## Next steps

* [Workflows](/ticketing/workflows): labels, transitions, permits, guards, and due policy
* [Forms](/ticketing/forms): schemas, bindings, and versions
* [Automation](/ticketing/automation): actions, trigger intake, and alarm work orders
* [Assignment and notifications](/ticketing/assignment-notifications)
* [Console guide](/ticketing/console)
* [API reference](/ticketing/api-reference)


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