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.
Top comments (0)