VOOZH about

URL: https://dev.to/issuecapture/jira-rest-api-a-practical-guide-for-saas-integrations-1n75

⇱ Jira REST API: A Practical Guide for SaaS Integrations - DEV Community


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}`;

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();
}

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.
}

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 }]
}

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();
}

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' },
 ],
 },
 ],
};

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
};

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,
 }));
}

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.