# Jira Service Management

> Send Jira Service Management requests and their public comments to Dimension Labs with the universal API.

This brings in each Jira Service Management request as one conversation: the customer's original request and every public comment on it, from the customer and from your agents. Internal comments stay out. You run it on your side: a small script pulls from the Jira Cloud REST APIs and posts each message to the [universal API](/api/tracker).

## What you need

- A Jira Cloud site with one or more service projects, and their project keys (for example `HELP`).
- An Atlassian account with agent access to those projects. The script sees only what this account can see: Jira needs the *Browse projects* permission to return an issue, and permission to view the request to return its comments.
- An API token for that account.
- A Dimension Labs API key for a new **Universal API** source. See [Your API key](/api/tracker#your-api-key).

## 1. Get API access in Jira Service Management

1. Signed in as the integration account, go to [id.atlassian.com/manage-profile/security/api-tokens](https://id.atlassian.com/manage-profile/security/api-tokens).
2. Select **Create API token**, give it a name that says what it does, and pick an expiration date. Tokens last 1 to 365 days, so note when to replace it.
3. Select **Create**, then **Copy to clipboard**. You can't see the token again later.
4. Authenticate with Basic auth: the account's email address and the token, against your site, for example `https://your-site.atlassian.net` ([manage API tokens](https://support.atlassian.com/atlassian-account/docs/manage-api-tokens-for-your-atlassian-account/)).

Use the plain **Create API token** option. A token with scopes calls a different base URL (`api.atlassian.com/ex/jira/{cloudId}`) and this guide doesn't cover it.

## 2. Pull new requests

Find requests that changed since your cursor with [enhanced JQL search](https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issue-search/), `GET /rest/api/3/search/jql`. The older `GET /rest/api/3/search` is being removed: don't build on it.

```bash
curl -u "$JSM_EMAIL:$JSM_API_TOKEN" -G \
  "https://your-site.atlassian.net/rest/api/3/search/jql" \
  --data-urlencode 'jql=project in ("HELP") AND updated >= 1760313600000 ORDER BY updated ASC' \
  --data-urlencode 'fields=summary,description,reporter,created,status,priority,issuetype,labels' \
  --data-urlencode 'maxResults=100'
```

- A number without quotes in a date clause is read as milliseconds since 1970, so the cursor needs no time zone. A quoted date such as `"2026-10-01 09:00"` is read in the account's Jira time zone.
- The query must be bounded. `project in (...)` does that.
- Each page holds `issues` and, unless it is the last page, a `nextPageToken`. Pass it back as `nextPageToken` for the next page.
- `description` comes back in Atlassian Document Format (JSON), not plain text. The code below turns it into text.

For each request, get the public comments from the [Jira Service Management API](https://developer.atlassian.com/cloud/jira/service-desk/rest/api-group-request/): `GET /rest/servicedeskapi/request/{issueIdOrKey}/comment?public=true&internal=false`. Each comment has a plain-text `body`, a `public` flag, `author.accountId` and `created.epochMillis`. Page with `start` and `limit` (default 50) until `isLastPage` is `true`. To tell customers from agents, also read the request's participants with `GET /rest/servicedeskapi/request/{issueIdOrKey}/participant`.

Rate limits, per Atlassian: API token traffic is governed by burst (per-second) limits. Going over returns `429` with a `Retry-After` header in seconds ([rate limiting](https://developer.atlassian.com/cloud/jira/platform/rate-limiting/)).

## 3. Map the fields

| Dimension Labs | Jira Service Management | Notes |
|---|---|---|
| `text` | `summary` and `description` for the first message; comment `body` after that | Only comments returned with `public=true&internal=false`. Internal comments are never requested. |
| `userId` | issue `reporter.accountId` | Atlassian's account id for the customer, on every message of the request. Account ids can contain a colon: the code replaces it with `-`. |
| `incoming` | comment `author.accountId` is the reporter or a participant | The request itself is incoming. Comments from anyone else (your agents) are outgoing. |
| `sessionId` | issue `key` | Prefix it, for example `jsm-HELP-123`. |
| `conversationId` | issue `key` | Same value as `sessionId`. |
| `dimensionlabs_timestamp` | issue `created`; comment `created.epochMillis` | Convert with `toMillis`. `epochMillis` is already in milliseconds. |
| `platformJson` | `status`, `priority`, `issuetype`, `labels`, project key | Add a request type or other custom field if you report on it. |

**Personal data.** Don't send the reporter's `emailAddress` or `displayName`. Requests and comments often hold names, emails and phone numbers: redact them before you send. See [PII redaction](/data/pii-redaction).

## 4. Send it

```js
// jira-service-management.mjs. Environment: JSM_SITE (your-site.atlassian.net), JSM_EMAIL, JSM_API_TOKEN,
// JSM_PROJECTS (project keys, for example HELP,IT), DL_API_KEY.


const HOUR = 60 * 60 * 1000;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const base = `https://${process.env.JSM_SITE}`;
const auth = 'Basic ' + Buffer.from(`${process.env.JSM_EMAIL}:${process.env.JSM_API_TOKEN}`).toString('base64');
const noColon = (id) => String(id).replace(/:/g, '-');

// Retries Atlassian's rate limits (429) and server errors (5xx), honoring Retry-After.
async function get(url) {
  for (let attempt = 0; ; attempt++) {
    const res = await fetch(url, { headers: { Authorization: auth, Accept: 'application/json' } });
    if (res.ok) return res.json();
    if ((res.status !== 429 && res.status < 500) || attempt === 5) throw new Error(`Jira returned ${res.status}: ${await res.text()}`);
    await sleep((Number(res.headers.get('retry-after')) || 2 ** attempt) * 1000);
  }
}

// Reads every page of a Jira Service Management list (start and limit, until isLastPage).
async function paged(path) {
  const items = [];
  for (let start = 0; ; ) {
    const page = await get(`${base}${path}${path.includes('?') ? '&' : '?'}start=${start}&limit=50`);
    items.push(...page.values);
    if (page.isLastPage || page.values.length === 0) return items;
    start += page.values.length;
  }
}

// Atlassian Document Format to plain text: the text nodes in order, a line break after each block.
function adfToText(node) {
  if (!node) return '';
  if (node.type === 'text') return node.text ?? '';
  if (node.type === 'hardBreak') return '\n';
  const inner = (node.content ?? []).map(adfToText).join('');
  return ['paragraph', 'heading', 'codeBlock'].includes(node.type) ? `${inner}\n` : inner;
}

// Sends every request and public comment written at or after `since` (Unix ms). Returns the new `since`.
export async function syncJsm({ since, historical = false }) {
  const projects = process.env.JSM_PROJECTS.split(',').map((p) => `"${p.trim()}"`).join(',');
  const jql = `project in (${projects}) AND updated >= ${Math.floor(since)} ORDER BY updated ASC`;
  const fields = 'summary,description,reporter,created,status,priority,issuetype,labels';
  let newest = since;
  let token = null;
  do {
    const page = await get(`${base}/rest/api/3/search/jql?jql=${encodeURIComponent(jql)}&fields=${fields}&maxResults=100${token ? `&nextPageToken=${encodeURIComponent(token)}` : ''}`);
    for (const issue of page.issues) {
      const f = issue.fields;
      const reporter = f.reporter?.accountId;
      const participants = await paged(`/rest/servicedeskapi/request/${issue.key}/participant`);
      const customers = new Set([reporter, ...participants.map((p) => p.accountId)].filter(Boolean));
      const send = async (text, incoming, ts) => {
        await sendToDimensionLabs({
          text,
          userId: reporter ? noColon(reporter) : `jsm-${issue.key}`,
          incoming,
          sessionId: `jsm-${issue.key}`,
          conversationId: `jsm-${issue.key}`,
          dimensionlabs_timestamp: ts,
          platformJson: { project: issue.key.split('-')[0], status: f.status?.name, priority: f.priority?.name, issuetype: f.issuetype?.name, labels: f.labels },
        }, { historical: historical || ts < Date.now() - HOUR });
        newest = Math.max(newest, ts);
      };

      const opened = toMillis(f.created);
      const request = [f.summary, adfToText(f.description)].filter((s) => s?.trim()).join('\n\n').trim();
      if (opened >= since && request) await send(request, true, opened);

      for (const c of await paged(`/rest/servicedeskapi/request/${issue.key}/comment?public=true&internal=false`)) {
        const ts = c.created?.epochMillis;
        if (!c.public || !ts || ts < since || !c.body?.trim()) continue;
        await send(c.body, customers.has(c.author?.accountId), ts);
      }
    }
    token = page.nextPageToken ?? null;
  } while (token);
  return newest;
}
```

Atlassian notes that very recent changes might not show up in search results straight away. The next run picks them up, and anything sent twice is removed automatically.

## 5. Run it

Save the code as `jira-service-management.mjs` in a project set up as in [Set up your project](/api/tracker#set-up-your-project), and run it with the [scheduled sync](/api/scheduled-sync) runner: `import { syncJsm as sync } from './jira-service-management.mjs';`.

- **History first:** `node run.mjs 2025-01-01` sends every request and public comment since that date to `/trackhistorical`. Then start a [backfill](/dimensions/backfill) from the **Backfill** tab.
- **Then every 15 minutes:** the scheduled job sends new messages to `/track`, and they are enriched automatically every day. A message that is more than an hour old when the job sees it goes to `/trackhistorical`, and needs a backfill.
- **Re-runs are safe:** duplicates are removed automatically.

Each request costs at least two extra calls (participants and comments), so a history load takes a while; the code waits whenever Jira asks it to. Replace the API token before it expires, or the job stops with a `401`. You can also [upload a file](/data/upload-a-file) with one row per comment (check it with the [file checker](/data/check-a-file)), then run a backfill.

## Jira Service Management reference

- [Manage API tokens for your Atlassian account](https://support.atlassian.com/atlassian-account/docs/manage-api-tokens-for-your-atlassian-account/)
- [Issue search (enhanced JQL search)](https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issue-search/)
- [Jira Service Management REST API: Request](https://developer.atlassian.com/cloud/jira/service-desk/rest/api-group-request/)
- [Jira Service Management REST API: introduction](https://developer.atlassian.com/cloud/jira/service-desk/rest/intro/)
- [JQL fields](https://support.atlassian.com/jira-software-cloud/docs/jql-fields/)
- [Atlassian Document Format](https://developer.atlassian.com/cloud/jira/platform/apis/document/structure/)
- [Rate limiting](https://developer.atlassian.com/cloud/jira/platform/rate-limiting/)

Checked against Jira Service Management's documentation on 2026-10-26.
