DEV Community

Cover image for Jira REST API: A Practical Guide for SaaS Integrations
Benji Darby
Benji Darby

Posted on

Jira REST API: A Practical Guide for SaaS Integrations

Three things burned me building a Jira integration for IssueCapture that the official docs don't warn you about: ADF for descriptions, refresh token rotation, and cloud_id discovery. What I expected to be a two-day integration took considerably longer.

This covers all three, plus the OAuth 2.0 flow end to end.

OAuth 2.0 (3LO): The Full Flow

Jira Cloud uses three-legged OAuth 2.0. No API tokens for SaaS integrations — you need actual user consent.

Step 1: Build the authorization URL

const params = new URLSearchParams({
  audience: 'api.atlassian.com',
  client_id: process.env.ATLASSIAN_CLIENT_ID,
  scope: 'read:jira-user read:jira-work write:jira-work offline_access',
  redirect_uri: 'https://yourapp.com/oauth/callback',
  state: crypto.randomUUID(),
  response_type: 'code',
  prompt: 'consent',
});

const authUrl = `https://auth.atlassian.com/authorize?${params}`;
Enter fullscreen mode Exit fullscreen mode

offline_access is required if you want a refresh token. Without it, access tokens expire in an hour and users have to re-authorize.

Step 2: Exchange the code for tokens

async function exchangeCode(code) {
  const response = await fetch('https://auth.atlassian.com/oauth/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      grant_type: 'authorization_code',
      client_id: process.env.ATLASSIAN_CLIENT_ID,
      client_secret: process.env.ATLASSIAN_CLIENT_SECRET,
      code,
      redirect_uri: 'https://yourapp.com/oauth/callback',
    }),
  });

  return response.json();
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Refresh before expiry

async function refreshAccessToken(refreshToken) {
  const response = await fetch('https://auth.atlassian.com/oauth/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      grant_type: 'refresh_token',
      client_id: process.env.ATLASSIAN_CLIENT_ID,
      client_secret: process.env.ATLASSIAN_CLIENT_SECRET,
      refresh_token: refreshToken,
    }),
  });

  return response.json();
  // IMPORTANT: Atlassian rotates refresh tokens.
  // Save the NEW refresh token or you'll be locked out.
}
Enter fullscreen mode Exit fullscreen mode

This catches most people off guard: Atlassian rotates refresh tokens. Every time you use a refresh token, you get a new one back. If you only save the new access token, your next refresh will fail with invalid_grant.

Discovering Accessible Resources (cloud_id)

Every Jira API call requires a cloud_id. Users might have access to multiple instances.

async function getAccessibleResources(accessToken) {
  const response = await fetch(
    'https://api.atlassian.com/oauth/token/accessible-resources',
    { headers: { Authorization: `Bearer ${accessToken}` } }
  );
  return response.json();
  // Returns: [{ id, name, url, scopes, avatarUrl }]
}
Enter fullscreen mode Exit fullscreen mode

If the user has multiple instances, let them pick one.

Creating Issues: The Fields That Trip You Up

The basic endpoint:

async function createJiraIssue(accessToken, cloudId, fields) {
  const response = await fetch(
    `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue`,
    {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${accessToken}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ fields }),
    }
  );
  return response.json();
}
Enter fullscreen mode Exit fullscreen mode

The tricky part is what goes inside fields.

Descriptions use Atlassian Document Format (ADF)

Plain strings don't work for the description field. Jira Cloud uses ADF:

const description = {
  type: 'doc',
  version: 1,
  content: [
    {
      type: 'paragraph',
      content: [
        {
          type: 'text',
          text: 'Clicking submit on checkout returns a 500 error.',
        },
      ],
    },
    {
      type: 'paragraph',
      content: [
        { type: 'text', text: 'Browser: ', marks: [{ type: 'strong' }] },
        { type: 'text', text: 'Chrome 124 on macOS 14' },
      ],
    },
  ],
};
Enter fullscreen mode Exit fullscreen mode

Priority and components use objects, not strings

const fields = {
  project: { key: 'PROJ' },
  summary: 'Checkout page returns 500 on submit',
  description,
  issuetype: { name: 'Bug' },
  priority: { name: 'High' },      // not just "High"
  components: [{ name: 'Payments' }], // array of objects
  labels: ['bug', 'checkout'],       // labels ARE plain strings
};
Enter fullscreen mode Exit fullscreen mode

Discovering Mandatory Fields

The createmeta endpoint tells you what a project and issue type require:

async function getCreateMeta(accessToken, cloudId, projectKey, issueTypeName) {
  const url = new URL(
    `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/createmeta`
  );
  url.searchParams.set('projectKeys', projectKey);
  url.searchParams.set('issuetypeNames', issueTypeName);
  url.searchParams.set('expand', 'projects.issuetypes.fields');

  const response = await fetch(url.toString(), {
    headers: { Authorization: `Bearer ${accessToken}` },
  });

  const meta = await response.json();
  const fields = meta.projects[0]?.issuetypes[0]?.fields ?? {};

  return Object.entries(fields)
    .filter(([, field]) => field.required)
    .map(([key, field]) => ({
      key,
      name: field.name,
      allowedValues: field.allowedValues,
    }));
}
Enter fullscreen mode Exit fullscreen mode

Call this during your setup flow and cache the result.

Common Failure Modes

cloud_id mismatch. The access token is scoped to the user, not the instance. Wrong cloud_id = 403 or 404 on every call.

invalid_grant on refresh. Almost always a stale refresh token. Implement locking around the refresh flow to prevent race conditions.

403 on issue creation. Check whether the project is company-managed or team-managed. Some API behaviors differ.

ADF validation errors. Jira's ADF parser is strict. A null in the content array, a missing version: 1, or an unsupported node type will return a 400 with an unhelpful message.


The OAuth and REST layer is fine. ADF is a pain and the createmeta endpoint is barely documented. Budget two days minimum for the integration, not two hours.

Top comments (0)