Jira REST API v3, Automation Patterns That Don't Hate You Back
TL;DR — Jira Cloud REST API v3 is solid once you stop fighting it. / The Atlassian Document Format is the single biggest source of suffering — wrap it once and never look at it again. / Use a scoped API token per integration, never a personal one, and always paginate with
nextPageTokenpatterns even when the response looks small.
If you’ve ever written a one-off script to “just close all the tickets in this sprint that match X” and watched it work in dev then quietly truncate at 50 results in production, you’ve already met one of the friendlier failure modes of the Jira REST API. This post is the writeup I should have given myself before starting.
The context: I’ve been wiring Jira Cloud into an n8n-powered automation backbone (covered in the previous post on self-hosting n8n) for a thirty-person engineering org. The patterns below are what survived contact with reality after about a hundred workflows and a few production incidents.
I’ll focus on Jira Cloud and v3 of the REST API. If you’re on Jira Data Center, several of these patterns still apply, but the auth story is different and you should check the Server/DC docs separately.
Auth: API tokens, not passwords, not OAuth (yet)
For server-to-server automation against Jira Cloud, the path of least pain is HTTP Basic auth with an email plus an Atlassian API token. OAuth 2.0 (3LO) is available, and you should use it if you’re building a multi-tenant app to ship on the Marketplace. For an internal automation calling your own tenant, it’s overkill.
Generate the token from id.atlassian.com/manage-profile/security/api-tokens. Critical: tokens inherit the full permissions of the user that created them. So:
- Create a dedicated service account in your Atlassian org. Don’t use a human’s account.
- Grant it the minimum project permissions it needs. Browse + Edit on the relevant projects, usually.
- Rotate the token quarterly. Stick it in your secret manager, not the n8n credential store if you can avoid it.
The actual request looks like this:
const axios = require('axios');
const client = axios.create({
baseURL: 'https://your-tenant.atlassian.net/rest/api/3',
auth: {
username: process.env.JIRA_EMAIL,
password: process.env.JIRA_API_TOKEN,
},
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
timeout: 15000,
});
That’s it. No bearer prefix, no signature, no nonsense. The Atlassian REST API auth docs cover the edge cases.
The Atlassian Document Format problem
The single largest design decision that surprised teams moving to v3 is that text fields — descriptions, comments, anything rich — are no longer plain strings or wiki markup. They’re ADF (Atlassian Document Format), a nested JSON structure modeled loosely on ProseMirror.
A simple “Hello, world.” comment becomes:
{
"body": {
"type": "doc",
"version": 1,
"content": [
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Hello, world." }
]
}
]
}
}
This is fine in theory. In practice, every automation I’ve written needs a helper. Here’s the one I keep reusing:
function toADF(plainText) {
const paragraphs = plainText.split('\n\n').filter(Boolean);
return {
type: 'doc',
version: 1,
content: paragraphs.map((p) => ({
type: 'paragraph',
content: [{ type: 'text', text: p }],
})),
};
}
function adfToPlain(adf) {
if (!adf || !adf.content) return '';
const walk = (node) => {
if (node.type === 'text') return node.text;
if (Array.isArray(node.content)) return node.content.map(walk).join('');
return '';
};
return adf.content.map(walk).join('\n\n').trim();
}
That covers about 95% of cases. For inline links, code blocks, or mentions, you need a real ADF builder. The @atlaskit/adf-utils package does the job but pulls in a fair chunk of dependencies. For server-side, I’ve had better luck with a small handwritten builder that targets only the node types my workflows actually produce.
If you’re using the n8n Jira node, ADF conversion is hidden behind the UI — but if you drop into a Function node to do something fancy, you’ll meet it directly.
JQL pagination, properly
Half the broken Jira scripts I’ve inherited share the same bug: they call /rest/api/3/search?jql=... once, get back 50 issues, and stop. The API returns paginated results, and the defaults are conservative.
The correct pattern, using startAt and maxResults:
async function searchAll(jql, fields = ['summary', 'status', 'assignee']) {
const results = [];
let startAt = 0;
const maxResults = 100;
while (true) {
const { data } = await client.get('/search', {
params: {
jql,
startAt,
maxResults,
fields: fields.join(','),
},
});
results.push(...data.issues);
if (startAt + data.issues.length >= data.total) break;
startAt += data.issues.length;
}
return results;
}
Two non-obvious bits. First, always specify fields explicitly. The default returns the whole issue payload — easily 50 KB per ticket if there are attachments and customfields — and you’ll hit the rate limit and your own memory limit much faster than you expect. Second, data.total is an estimate on large queries. Atlassian’s been quietly migrating to a cursor-based approach for some endpoints, so don’t be surprised if total is missing in future revisions; rely on data.issues.length < maxResults as your stop condition for forward compatibility.
Webhooks, with signature verification
The Jira webhook docs make signature verification look optional. It is not, if you care about anyone not being able to forge ticket-transition events at your automation server.
Configure the webhook in https://your-tenant.atlassian.net/plugins/servlet/webhooks with a secret, then verify on receipt:
const crypto = require('crypto');
function verifyJiraSignature(rawBody, signatureHeader, secret) {
if (!signatureHeader) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
// Header format: "sha256=<hex>"
const provided = signatureHeader.replace(/^sha256=/, '');
return crypto.timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(provided, 'hex')
);
}
In n8n, the easiest way to do this is a webhook node configured for “raw body” mode, then a Function node that pulls x-hub-signature and runs the comparison before any subsequent logic fires.
Rate limits
Atlassian’s rate limits on Jira Cloud are dynamic and per-tenant. The official guidance is to honor the Retry-After header on 429 responses, which is the right approach. What the docs underplay: bulk creation and bulk-edit endpoints have separate, tighter limits than the issue-fetch endpoints, and JQL search counts against a different bucket than single-issue GET.
A simple retry wrapper that’s worked well:
async function withRetry(fn, maxAttempts = 5) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (err) {
if (err.response?.status !== 429 || attempt === maxAttempts) throw err;
const retryAfter = parseInt(err.response.headers['retry-after'] || '5', 10);
const jitter = Math.random() * 500;
await new Promise((r) => setTimeout(r, retryAfter * 1000 + jitter));
}
}
}
Jitter matters. Without it, every worker hitting the limit at once will retry in lockstep and hit it again.
Common Pitfalls
- Custom field IDs are not portable.
customfield_10042in your sandbox might becustomfield_10089in production. Fetch the field map on startup with/rest/api/3/fieldand resolve by name in your code. assigneesemantics changed for GDPR. You assign byaccountId, not username or email. To look up an accountId from an email, call/rest/api/3/user/search?query=<email>— and yes, this requires the “Browse users” permission, which is locked down by default.- Transitions don’t take field names. When transitioning an issue with
POST /issue/{key}/transitions, you have to use the transition ID, not the destination status name. Fetch/issue/{key}/transitionsfirst to discover the valid IDs for that issue in its current state. - The
commentfield on creation is rejected. You can’t pass a comment in the issue-create payload; you have to create the issue first, thenPOST /issue/{key}/comment. I’ve seen people lose half a day to this. - Webhook payloads are not idempotent. Atlassian will retry on any non-2xx response, and they’ll sometimes retry on a 2xx response if their internal acker times out. Make your handlers idempotent — key off
issue.idplus event timestamp.
Wrapping Up
The Jira REST API rewards a small amount of upfront investment in helpers — an ADF converter, a paginator, a retry wrapper, a custom-field resolver. Build those four things once, drop them into your toolbox, and the rest of your Jira automation work goes from “frustrating” to “tedious-but-fine.”
Next up in this series: the same problem, but on Linear, which has a GraphQL API and a wildly different set of opinions about almost everything.