Skip to content

02 Get your data in /Integrations

Jira

Send Jira issues and their comments to Dimension Labs with the universal API.

  1. J JiraTickets, chats, calls, reviews
  2. ⟳ Your jobPull new · map fields · every 15 min
  3. { } Universal APIPOST /track · /trackhistorical
  4. Dimension LabsRead in full · enriched daily

This brings in each issue from a customer-facing Jira project (a feedback, bug report or service project) as one conversation: the issue’s summary and description, then its public comments, from the reporter and from your team. You run it on your side: a small script pulls from the Jira Cloud REST API and posts each message to the universal API.

  • A Jira Cloud site and an Atlassian account that can browse the project. The script sees only what that account can see.
  • An API token for that account, and the project’s key (for example FEED).
  • A Dimension Labs API key for a new Universal API source. See Your API key.
  1. Log in to https://id.atlassian.com/manage-profile/security/api-tokens.
  2. Select Create API token, give it a name, and pick an expiration date (1 to 365 days).
  3. Select Create, then Copy to clipboard. You can’t see the token again.
  4. Authenticate with Basic auth: your Atlassian account email and the token (manage API tokens, basic auth for REST APIs).

Tokens expire, so note the date and make a new one before then.

Search with JQL using the enhanced search endpoint, /rest/api/3/search/jql. Atlassian marks the older /rest/api/3/search as being removed, so don’t build on it.

Terminal window
curl -u "$JIRA_EMAIL:$JIRA_API_TOKEN" -G "https://YOUR_SITE.atlassian.net/rest/api/3/search/jql" \
--data-urlencode 'jql=project = "FEED" AND updated >= "-60m" ORDER BY updated ASC' \
--data-urlencode 'fields=summary,description,reporter,created,status,issuetype,priority,labels' \
--data-urlencode 'maxResults=100'
  • Pass the response’s nextPageToken to get the next page. It is missing on the last page.
  • JQL dates are read in the time zone set on the account. The code avoids that by asking for a relative time ("-60m" is the last 60 minutes).

For each issue, get comments with GET /rest/api/3/issue/ISSUE_ID/comment?orderBy=created. Comments page with startAt and maxResults, and the response carries total.

Version 3 of the API returns the issue description and each comment body in Atlassian Document Format (ADF): a JSON tree of nodes, where text nodes hold the words. The code’s adfToText walks the tree and joins the text into plain text, one line per paragraph or list item. It leaves out mention nodes, because they hold people’s names.

Rate limits, per Atlassian: each endpoint allows about 100 GET requests per second per site, and going over returns 429 with a Retry-After header. The hourly points quotas apply to Forge, Connect and OAuth apps, not to API token traffic (rate limiting).

Dimension LabsJiraNotes
textissue summary and description; comment bodyFlatten ADF with adfToText. Skip comments where jsdPublic is false (internal comments in a service project) and comments with a visibility restriction.
userIdissue reporter.accountIdThe Atlassian account id, on every message of the issue. Fall back to the sessionId.
incomingcomment authorThe issue itself is true. A comment is true when its author is the reporter or a service desk customer (accountType is customer); anyone else counts as your team. If people outside your team comment on issues they didn’t report, change this rule.
sessionIdissue idPrefix it, for example jira-10042.
conversationIdissue idSame value as sessionId.
dimensionlabs_timestampissue created; comment createdConvert with toMillis. Jira’s offsets look like +0000, which toMillis reads.
platformJsonkey, issuetype.name, status.name, priority.name, labels; comment idAdd any custom field you need to fields in the search.

Personal data. Don’t send emailAddress or displayName from the reporter or comment authors. Issues and comments can hold names, emails and account details: redact them before you send. See PII redaction.

// jira.mjs. Environment: JIRA_SITE (your-site.atlassian.net), JIRA_EMAIL, JIRA_API_TOKEN, JIRA_PROJECT (the project key), DL_API_KEY.
import { sendToDimensionLabs, toMillis } from './dimensionlabs.mjs';
const HOUR = 60 * 60 * 1000;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const base = `https://${process.env.JIRA_SITE}/rest/api/3`;
const auth = 'Basic ' + Buffer.from(`${process.env.JIRA_EMAIL}:${process.env.JIRA_API_TOKEN}`).toString('base64');
// Retries Jira'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);
}
}
// Flattens Atlassian Document Format to plain text. Mentions are dropped: they hold people's names.
const INLINE = new Set(['text', 'hardBreak', 'mention', 'emoji', 'inlineCard', 'date', 'status', 'mediaInline']);
export function adfToText(node) {
if (!node || typeof node !== 'object') return typeof node === 'string' ? node : '';
if (node.type === 'text') return node.text ?? '';
if (node.type === 'hardBreak') return '\n';
if (node.type === 'emoji') return node.attrs?.text ?? '';
if (node.type === 'inlineCard') return node.attrs?.url ?? '';
if (node.type === 'mention') return '';
const kids = node.content ?? [];
const joined = kids.map(adfToText).join(kids.every((k) => INLINE.has(k.type)) ? '' : '\n');
return node.type === 'doc' ? joined.replace(/\n{3,}/g, '\n\n').trim() : joined;
}
// The issue and its public comments, as messages.
async function messagesFor(issue) {
const f = issue.fields;
const reporter = f.reporter?.accountId;
const out = [{ text: [f.summary, adfToText(f.description)].filter(Boolean).join('\n\n'), incoming: true, ts: toMillis(f.created) }];
for (let startAt = 0; ; ) {
const page = await get(`${base}/issue/${issue.id}/comment?orderBy=created&startAt=${startAt}&maxResults=100`);
for (const c of page.comments) {
if (c.jsdPublic === false || c.visibility) continue; // internal or restricted comments
const a = c.author ?? {};
out.push({ text: adfToText(c.body), incoming: a.accountId === reporter || a.accountType === 'customer', ts: toMillis(c.created), comment_id: c.id });
}
startAt += page.comments.length;
if (!page.comments.length || startAt >= page.total) break;
}
return out;
}
// Sends every issue and public comment written at or after `since` (Unix ms). Returns the new `since`.
export async function syncJira({ since, historical = false }) {
let newest = since;
const minutes = Math.ceil((Date.now() - since) / 60000) + 1; // relative time: no time zone to get wrong
const jql = `project = "${process.env.JIRA_PROJECT}" AND updated >= "-${minutes}m" ORDER BY updated ASC`;
for (let token = null, first = true; first || token; first = false) {
const params = new URLSearchParams({ jql, maxResults: '100', fields: 'summary,description,reporter,created,status,issuetype,priority,labels' });
if (token) params.set('nextPageToken', token);
const page = await get(`${base}/search/jql?${params}`);
for (const issue of page.issues) {
const sessionId = `jira-${issue.id}`;
const f = issue.fields;
for (const m of await messagesFor(issue)) {
if (m.ts < since || !m.text.trim()) continue;
await sendToDimensionLabs({
text: m.text,
userId: f.reporter?.accountId ?? sessionId,
incoming: m.incoming,
sessionId,
conversationId: sessionId,
dimensionlabs_timestamp: m.ts,
platformJson: { issue_key: issue.key, issue_type: f.issuetype?.name, status: f.status?.name, priority: f.priority?.name, labels: f.labels, comment_id: m.comment_id },
}, { historical: historical || m.ts < Date.now() - HOUR });
newest = Math.max(newest, m.ts);
}
}
token = page.nextPageToken ?? null;
}
return newest;
}

Save the code as jira.mjs in a project set up as in Set up your project, and run it with the scheduled sync runner: import { syncJira as sync } from './jira.mjs';.

  • History first: node run.mjs 2025-01-01 sends every issue and comment written since that date, on issues updated since then, to /trackhistorical. Then start a backfill from the Backfill tab.
  • Then every 15 minutes: the scheduled job sends new issues and comments 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 issue costs at least one comment call, so a history load of a busy project takes a while; the code waits whenever Jira asks it to. You can also upload a file of exported issues (check it with the file checker), then run a backfill.

Checked against Atlassian’s documentation on 2026-10-26.