> For the complete documentation index, see [llms.txt](https://docs.duku.ai/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.duku.ai/integrations/issues-api.md).

# Issues API

Pull Duku findings into your tools and write triage decisions back.

Most teams use this to hand Duku's findings to whatever they already build with - a coding agent, a script, or their own issue tracker. Every entry says what is broken and carries the exact request, message, and step that produced it, so an agent can act on it without anyone re-describing the bug first.

All operations need a Bearer token. See [API Keys](/integrations/api-keys.md).

### Before you start

You need two things.

**An API key** - in the dashboard, go to **Settings → API Keys → Generate API Key**. It is shown once, so copy it straight away. A key can only see the organisation it was created in. See [API Keys](/integrations/api-keys.md) for how keys work.

**Your product ID** - open the product in the dashboard and take the `sms_...` value out of the URL:

```
https://app.duku.ai/p/sms_AbC123XyZ456
                      ^^^^^^^^^^^^^^^^
                      your product ID
```

### Get everything for a product in one query

```graphql
query AllIssues($subjectId: ID!) {
  issueGroups(subjectId: $subjectId, status: OPEN, sortBy: PRIORITY, sortDirection: desc) {
    groupKey
    title
    description
    bucket
    issueCount
    occurrenceCount
    lastSeenAt
    signals { id }
  }
  issuesConnection(subjectId: $subjectId, status: OPEN, first: 500, sortBy: PRIORITY, sortDirection: desc) {
    totalCount
    pageInfo { hasNextPage endCursor }
    edges { node {
        id
        title
        category
        origin
        occurrenceCount
        representativeUrl
        occurrences(first: 1) {
          runId
          sampleError {
            type message statusCode requestMethod requestUrl url
            sourceFile lineNumber columnNumber actionDescription
          }
        }
    } }
  }
}
```

`subjectId` is your product ID - the `sms_...` in the dashboard URL.

To run it, see [Save it as JSON](#save-it-as-json) below - it covers exchanging your key for a token and posting the query.

**`issueGroups`** are the problems: one entry per underlying cause, each with a written explanation and the ids of the signals behind it. **`issuesConnection`** is every individual error, with the full detail of what failed. Join them on `signals[].id` to `edges[].node.id`. See [Issues & signals](/core-concepts/issues-and-signals.md) for how Duku groups errors.

Both are needed - a group lists which signals belong to it, but the error detail lives on the signal. Products that are not grouped yet return `issueGroups` as an empty array and the full picture in `issuesConnection`, so the same query works either way.

### Save it as JSON

Export your key, then save the query as `query.json` with your product ID as the variable:

```bash
export DUKU_API_KEY='<paste your key here>'
```

```json
{
  "query": "query AllIssues($subjectId: ID!) { ... }",
  "variables": { "subjectId": "sms_AbC123XyZ456" }
}
```

```bash
DECODED=$(printf '%s' "$DUKU_API_KEY" | base64 --decode)
TOKEN=$(curl -s -X POST https://auth.duku.ai/realms/duku/protocol/openid-connect/token \
  -d grant_type=client_credentials \
  -d "client_id=${DECODED%%:*}" \
  -d "client_secret=${DECODED#*:}" | jq -r .access_token)

curl -s https://platform.duku.ai/graphql \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d @query.json | jq '.data' > duku-issues.json
```

Re-run it on a schedule, or after each deploy, to keep a current copy alongside your own issue data.

### What comes back

On each entry in `issueGroups`:

| Field             | What it is                                                     |
| ----------------- | -------------------------------------------------------------- |
| `title`           | A one-line statement of the problem                            |
| `description`     | What a user does, what the app does wrong, and what to look at |
| `bucket`          | Severity - `CRITICAL`, `HIGH`, `MEDIUM`, `LOW`                 |
| `issueCount`      | How many signals are behind this entry                         |
| `occurrenceCount` | How many distinct **runs** hit it - not how many errors        |
| `lastSeenAt`      | When it was last observed                                      |
| `signals`         | The ids of the individual errors behind it                     |
| `groupKey`        | The identifier, and what `issueGroup(id:)` takes               |

On each signal in `issuesConnection`:

| Field                                                     | What it is                                                                                                    |
| --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| `id`                                                      | The signal's identifier - join it to `signals[].id`, and pass it to `updateIssueStatus`                       |
| `title`                                                   | A one-line label for this specific error                                                                      |
| `category`                                                | The kind of error, for example `api_error` or `console_error`                                                 |
| `origin`                                                  | `CLIENT_APP` for your own code, `THIRD_PARTY` for an external script, `UNKNOWN` if it could not be determined |
| `occurrenceCount`                                         | How many times this error was seen                                                                            |
| `representativeUrl`                                       | The page or endpoint it is most associated with                                                               |
| `occurrences[].runId`                                     | The run it was captured in, for looking up in the dashboard                                                   |
| `sampleError.message`                                     | The verbatim error, including the stack for JavaScript errors                                                 |
| `sampleError.type`                                        | `CONSOLE_ERROR`, `HTTP_CLIENT_ERROR`, `HTTP_SERVER_ERROR`, `JS_EXCEPTION`, `NETWORK_FAILURE`                  |
| `sampleError.actionDescription`                           | The step that triggered it, for example `Click on <button>: Delete`                                           |
| `sampleError.requestMethod` / `requestUrl` / `statusCode` | The failed request                                                                                            |
| `sampleError.sourceFile` / `lineNumber` / `columnNumber`  | Where a JavaScript error was thrown                                                                           |
| `sampleError.url`                                         | The page it happened on                                                                                       |

Which fields are filled depends on the error type - an HTTP error carries the request but no source file, a console error the reverse - so expect nulls rather than treating them as missing data.

{% hint style="info" %}
`occurrenceCount` on an entry counts distinct runs, not errors. An entry with 9 signals can report 63 - that is 63 runs affected. `type` is a string, not a fixed set, so treat unfamiliar values as "something else" rather than failing on them.
{% endhint %}

### Narrowing it down

Both queries take `status` and sort with `sortBy` (`PRIORITY`, `LAST_SEEN`, `FIRST_SEEN`, `OCCURRENCES`). Beyond that they differ:

`issueGroups` also filters on `category` and `targetId`; `issuesConnection` also filters on `search`, `url` and `origin`.

{% hint style="warning" %}
`sortDirection` is lowercase - `asc` or `desc` - while `sortBy` is uppercase. Sending `DESC` fails validation, and because the field is nullable you get a `null` result with the reason in `errors[]` rather than an obvious failure.
{% endhint %}

`first` defaults to 100 and is capped at 500 - ask for more and you silently get 500, so page with `after` from `pageInfo.endCursor` rather than raising it. `issuesConnection` is forward-only: `last` and `before` are rejected.

To pull one entry on its own, pass its `groupKey`:

```graphql
query One($id: ID!) {
  issueGroup(id: $id) {
    title
    description
    signals { id title }
    occurrences(first: 100) { runId sampleError { type message } }
  }
}
```

`occurrences(first:)` returns 100 by default and up to 1000. It gives one occurrence per run, so use `issuesConnection` when you need every signal.

### Writing back status and priority

Statuses are `OPEN`, `TRIAGED`, `RESOLVED` and `IGNORED`.

| Status     | Use it for                                        |
| ---------- | ------------------------------------------------- |
| `OPEN`     | Newly found, not yet triaged                      |
| `TRIAGED`  | Acknowledged and prioritised                      |
| `RESOLVED` | Fix deployed, not expected to recur               |
| `IGNORED`  | Known, won't-fix, or noise you do not want to see |

Set one signal's status:

```graphql
mutation Resolve($id: ID!) {
  updateIssueStatus(id: $id, input: { status: RESOLVED }) { id status }
}
```

Set it across a whole entry by passing that entry's `signals[].id` values:

```graphql
mutation ResolveMany($ids: [ID!]!) {
  bulkUpdateIssueStatus(input: { ids: $ids, status: RESOLVED }) { id status }
}
```

Set an entry's priority with `setIssueGroupPriority(id:, input: { priority: HIGH })`, using `CRITICAL`, `HIGH`, `MEDIUM` or `LOW`.

{% hint style="warning" %}
A status you set is never changed back automatically. If an issue you marked `RESOLVED` happens again, it stays `RESOLVED` - only its `lastSeenAt` and occurrence count move. It will not reappear in a `status: OPEN` query, so to catch regressions, check `lastSeenAt` on your resolved and ignored issues rather than relying on them reopening.
{% endhint %}

Status and priority writes need only the **Member** role, so an ordinary API key can triage from a pipeline.
