Migrate to RunxBuild and earn up to $50 in hosting credit on your first deposit.

Calculate your savings
unxBuild

The Jira REST API: Authentication, Search, and the Pagination Trap

Sean

Platform Writer

Aug 10, 2026
8 min read

Authenticate to the Jira Cloud REST API with your email address and an API token using HTTP Basic auth — not your password, which has not worked for Cloud since 2019. From there the API is straightforward apart from two things that catch every first integration: JQL search pagination and the difference between Cloud and Data Center endpoints.

The Jira REST API: Authentication, Search, and the Pagination Trap

Jira’s API is large, well documented, and has enough historical layers that it is easy to follow the wrong page. This covers the parts you need for a working integration and the specific places where the obvious approach fails at scale.

Table of contents

Authentication

For Jira Cloud, generate an API token from your Atlassian account security settings and use it as the password in Basic auth.

# Basic auth: email as username, API token as password
curl -u 'you@example.com:YOUR_API_TOKEN' \
  -H 'Accept: application/json' \
  'https://your-domain.atlassian.net/rest/api/3/myself'

# The header form, if your client does not build it for you
# base64 of "email:token"
curl -H 'Authorization: Basic ZW1haWxAZXhhbXBsZS5jb206dG9rZW4=' \
  -H 'Accept: application/json' \
  'https://your-domain.atlassian.net/rest/api/3/myself'

Hit /rest/api/3/myself first. It confirms authentication works and returns your accountId, which you will need — Jira Cloud identifies users by opaque account ID rather than by username, for privacy reasons.

The options, and when each applies:

  • API token with Basic auth — personal scripts and internal integrations. Simple, and tied to your account.
  • OAuth 2.0 (3LO) — apps acting on behalf of other users. Required for anything you distribute.
  • Personal Access Tokens — Jira Data Center and Server only, sent as a Bearer token.
  • Forge or Connect apps — for Marketplace apps, with their own authentication model.

Cloud and Data Center differ enough to matter. Cloud uses /rest/api/3/ with Atlassian Document Format for rich text; Data Center uses /rest/api/2/ with wiki markup. Code written against one will not run unchanged against the other, and the documentation does not always make it obvious which you are reading.

Reading and searching issues

# One issue by key
curl -u "$JIRA_USER:$JIRA_TOKEN" \
  'https://your-domain.atlassian.net/rest/api/3/issue/PROJ-123'

# Only the fields you need -- responses are large by default
curl -u "$JIRA_USER:$JIRA_TOKEN" \
  'https://your-domain.atlassian.net/rest/api/3/issue/PROJ-123?fields=summary,status,assignee'

# Search with JQL
curl -u "$JIRA_USER:$JIRA_TOKEN" \
  -G 'https://your-domain.atlassian.net/rest/api/3/search/jql' \
  --data-urlencode 'jql=project = PROJ AND status != Done ORDER BY created DESC' \
  --data-urlencode 'fields=summary,status,assignee,created' \
  --data-urlencode 'maxResults=100'

Always specify fields. The default response includes every field on the issue, including custom fields, and on a mature Jira instance that is a large payload per issue. Requesting three fields instead of ninety changes both response time and your rate-limit consumption.

JQL is the query language and it is worth learning properly:

project = PROJ AND status = "In Progress"
assignee = currentUser() AND resolution = Unresolved
created >= -7d ORDER BY created DESC
labels IN (backend, urgent) AND priority > Medium
project = PROJ AND updated >= "2026/08/01"
"Story Points" > 5 AND sprint IN openSprints()
issuetype = Bug AND status CHANGED TO Done AFTER -30d

Custom fields appear as customfield_10016 in responses. Find their IDs once and store the mapping rather than hardcoding numbers that mean nothing to the next reader:

curl -u "$JIRA_USER:$JIRA_TOKEN" \
  'https://your-domain.atlassian.net/rest/api/3/field' \
  | jq -r '.[] | select(.custom) | "\(.id)\t\(.name)"'

The pagination trap

This is the one that breaks integrations at exactly the wrong moment: they work in testing against fifty issues and silently truncate against five thousand.

Jira caps results per request — typically 100 — regardless of what you ask for. Atlassian has moved the search endpoint from offset-based to token-based pagination, and code written against the old startAt parameter will not page correctly on the new endpoint.

import os, requests

BASE = 'https://your-domain.atlassian.net'
AUTH = (os.environ['JIRA_USER'], os.environ['JIRA_TOKEN'])

def search_all(jql, fields):
    """Yield every issue matching jql, following pagination tokens."""
    token = None
    while True:
        params = {'jql': jql, 'fields': fields, 'maxResults': 100}
        if token:
            params['nextPageToken'] = token

        r = requests.get(f'{BASE}/rest/api/3/search/jql',
                         auth=AUTH, params=params, timeout=30)
        r.raise_for_status()
        payload = r.json()

        yield from payload.get('issues', [])

        token = payload.get('nextPageToken')
        if not token or payload.get('isLast'):
            break

for issue in search_all('project = PROJ AND status != Done',
                        'summary,status,assignee'):
    print(issue['key'], issue['fields']['summary'])

Never assume one request returned everything. Check for a continuation token and loop until it is absent. The failure mode of getting this wrong is not an error — it is a report that quietly covers the first hundred issues and looks plausible.

For genuinely large exports, narrow the JQL rather than paging through everything. Filtering by updated >= -1d and running incrementally is far cheaper than pulling the whole project nightly.

Creating and updating issues

curl -u "$JIRA_USER:$JIRA_TOKEN" \
  -X POST \
  -H 'Content-Type: application/json' \
  'https://your-domain.atlassian.net/rest/api/3/issue' \
  -d '{
    "fields": {
      "project":   { "key": "PROJ" },
      "summary":   "Deploy pipeline fails on main",
      "issuetype": { "name": "Bug" },
      "description": {
        "type": "doc",
        "version": 1,
        "content": [{
          "type": "paragraph",
          "content": [{ "type": "text", "text": "Build 4821 failed at the test stage." }]
        }]
      }
    }
  }'

That description field is Atlassian Document Format, and it surprises everyone the first time. Jira Cloud’s API v3 will not accept a plain string for rich-text fields — it wants a structured document. API v2 on Data Center accepts plain text with wiki markup, which is one more reason the two are not interchangeable.

Status changes are not field updates. Jira enforces its workflow, so you must use a transition, and the available transitions depend on the issue’s current status:

# What transitions are available from here?
curl -u "$JIRA_USER:$JIRA_TOKEN" \
  'https://your-domain.atlassian.net/rest/api/3/issue/PROJ-123/transitions'

# Perform one, by its ID
curl -u "$JIRA_USER:$JIRA_TOKEN" -X POST \
  -H 'Content-Type: application/json' \
  'https://your-domain.atlassian.net/rest/api/3/issue/PROJ-123/transitions' \
  -d '{"transition": {"id": "31"}}'

Transition IDs vary per workflow and per project. Fetch them rather than hardcoding, or your integration breaks the first time someone edits the workflow.

Rate limits and resilience

Jira Cloud applies rate limits and returns 429 with a Retry-After header when you exceed them. The limits are not published as fixed numbers — they vary by endpoint and instance — so respect the header rather than trying to model them.

import time, requests

def request_with_backoff(method, url, max_attempts=5, **kwargs):
    for attempt in range(max_attempts):
        r = requests.request(method, url, timeout=30, **kwargs)

        if r.status_code == 429:
            wait = int(r.headers.get('Retry-After', 2 ** attempt))
            time.sleep(wait)
            continue

        if r.status_code >= 500:
            time.sleep(2 ** attempt)
            continue

        r.raise_for_status()
        return r

    raise RuntimeError(f'giving up on {url} after {max_attempts} attempts')
  • Request only the fields you need. The cheapest optimisation available.
  • Use webhooks instead of polling. Polling every minute for changes is wasteful and hits limits; a webhook fires when something actually happens.
  • Cache what does not change — project metadata, field definitions, transition IDs.
  • Batch with JQL rather than fetching issues one key at a time.

The webhook point is the important one architecturally. A webhook receiver needs a public HTTPS endpoint, signature verification, and somewhere durable to record what arrived — the same shape as any webhook integration, with a service and a managed database behind it. The database documentation covers the persistence side, and returning 200 quickly while processing asynchronously keeps Jira from retrying work you already accepted.

How this fits the rest of the stack

Use an API token with Basic auth, confirm with /myself, always specify the fields you want, and follow pagination tokens until they run out — that last one is where naive integrations silently truncate. Prefer webhooks to polling, and respect Retry-After rather than guessing at limits. If you are building an integration that needs a public endpoint and somewhere durable to write, the RunxBuild hosting calculator shows the service and database as separate line items.

Useful related references:

FAQ

How do I authenticate to the Jira REST API?

For Jira Cloud, use HTTP Basic auth with your email address as the username and an API token from your Atlassian account security settings as the password. Passwords have not worked for Cloud since 2019.

What is the difference between Jira API v2 and v3?

v3 is Jira Cloud and uses Atlassian Document Format for rich text fields. v2 is used by Data Center and Server and accepts plain text with wiki markup. Code written for one will not run unchanged against the other.

Why does my Jira search only return 100 issues?

Jira caps results per request regardless of the maxResults you ask for. You must follow the pagination token in the response and keep requesting until it is absent. Missing this silently truncates results rather than raising an error.

How do I change an issue’s status through the API?

Not by updating the status field. Fetch the available transitions from the issue’s transitions endpoint, then POST the transition ID. Jira enforces its workflow, so the available transitions depend on the current status.

What are the Jira API rate limits?

Atlassian does not publish fixed numbers — they vary by endpoint and instance. Handle 429 responses by honouring the Retry-After header, and reduce load by requesting only the fields you need and using webhooks instead of polling.

#jira rest api#atlassian api#jql#api token#webhooks