docs(jira): add REST v3 references for auth, search duality, issues

Two new reference files distilled from live Atlassian docs research:
rest-auth-and-search.md (basic-auth email+token vs OAuth/PAT split,
rate-limit headers, error envelopes, legacy /search offset paging vs
enhanced /search/jql nextPageToken model with CHANGE-2046 deprecation
status) and rest-issues-and-transitions.md (issue CRUD shapes, the
GET-transitions-then-POST flow with screen-field requirements and
resolution semantics, ADF document model). Both carry Sources footers.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
Magnus Hedemark
2026-08-26 06:09:16 -04:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent 3ff46ee934
commit 32474b2848
2 changed files with 408 additions and 0 deletions
+194
View File
@@ -0,0 +1,194 @@
# Jira Cloud REST API v3 — Auth, Search & Pagination
Ground truth for authenticating against Jira Cloud and for running searches under both pagination models. Every claim traces to the Atlassian sources in the footer.
## Authentication
### Basic auth: email + API token (the default for scripts and CLIs)
Atlassian's recommended method "for personal scripts, bots, and ad-hoc execution of the REST APIs":
1. Create an API token at `https://id.atlassian.com/manage/api-tokens` (shown once; cannot be recovered later).
2. Build the string `email:api_token` — email is your **Atlassian account email address**, never a password.
3. Base64-encode it and send it as a proactively-supplied header:
```
Authorization: Basic base64(email:token)
```
`requests` does this via `auth=(email, token)` tuple — no manual base64 needed.
Facts worth knowing:
- API tokens work even when the org has two-factor authentication or SAML enabled.
- Since December 15 2024 new tokens expire after one year by default (configurable 1 day1 year); tokens created before that were retroactively given expiry from March 13 2025. Expired tokens surface as 401s.
- Password authentication is fully deprecated; there is no password fallback.
- Jira does not send an auth challenge — clients must send the header unprompted.
- Tokens can optionally carry OAuth-style scopes; scoped tokens are used against `https://api.atlassian.com/ex/jira/{cloudId}` instead of the site URL. Scopeless tokens keep working against `https://your-domain.atlassian.net`.
### The other methods, and when they apply
| Method | Applies to | Header | Base URL |
|--------|-----------|--------|----------|
| Basic + API token | personal scripts, bots | `Basic base64(email:token)` | site URL |
| OAuth 2.0 (3LO) | integrations acting for users, distributable apps | `Bearer ACCESS_TOKEN` | `https://api.atlassian.com/ex/jira/{cloudId}` |
| Forge / Connect apps | apps on those platforms | built-in JWT/requestJira | varies |
### PATs: Data Center yes, Cloud no
Personal Access Tokens (`Authorization: Bearer <token>`) exist only on **Data Center/Server** (Jira 8.14+). Jira Cloud has no PAT feature; its equivalent is the API-token model above. Point anyone asking about "Bearer tokens for Jira" at DC docs or to 3LO for Cloud.
### CAPTCHA lockout symptom
After repeated failed logins Jira may trigger CAPTCHA, which blocks REST auth entirely. Symptom: response header `X-Seraph-LoginReason: AUTHENTICATION_DENIED` — login rejected "without even checking the password". Fix by logging in through the browser once, not by retrying the script.
## Rate Limits
Three systems, all surfacing as HTTP 429 with these headers:
```
Retry-After: <seconds>
X-RateLimit-Limit: <ceiling>
X-RateLimit-Remaining: 0
X-RateLimit-Reset: <ISO 8601 timestamp>
RateLimit-Reason: jira-quota-global-based
```
`RateLimit-Reason` values: `jira-quota-global-based`, `jira-quota-tenant-based` (hourly point quotas), `jira-burst-based` (per-second buckets; defaults ~100 req/s GET/POST), `jira-per-issue-on-write` (20 writes/2s per issue). Honor `Retry-After`; back off with jitter rather than tight retries.
## Error Envelopes
Standard error collection everywhere in v3:
```json
{ "errorMessages": ["..."], "errors": {"field_name": "message"}, "status": 400 }
```
| Status | Meaning |
|--------|---------|
| 400 | Malformed request or bad JQL ("Field 'X' does not exist..."); `errors` map names offending fields |
| 401 | Credentials rejected/expired (or CAPTCHA-gated — check `X-Seraph-LoginReason`) |
| 403 | Authenticated but lacking permission |
| 404 | Resource absent or invisible |
| 422 | Validation failure on create/edit payloads |
| 429 | Rate limited — see headers above |
## Search Endpoints: the Duality
This is the single most important operational fact about Jira Cloud search in the current era: **two search endpoints with incompatible pagination models coexist**, and the legacy one is being removed.
### Legacy: `GET|POST /rest/api/3/search` — offset paging
Status: documented as "**Currently being removed**" and marked deprecated in the OpenAPI spec. Announced 31 October 2024 with removal promised "after May 1, 2025" (CHANGE-2046); sunset has proceeded gradually since.
Request parameters: `jql`, `startAt` (default 0), `maxResults` (default 50), `validateQuery` (`strict` default | `warn` | `none`), `fields`, `expand`, `properties`, `fieldsByKeys`, `failFast`.
Response envelope (`SearchResults`):
```json
{
"issues": [{"id": "10002", "key": "ED-1", "fields": {}}],
"startAt": 0,
"maxResults": 50,
"total": 1,
"warningMessages": []
}
```
Loop shape:
```python
start_at = 0
while True:
page = get("/search", params={"jql": jql, "startAt": start_at, "maxResults": 100})
yield from page["issues"]
start_at += len(page["issues"])
if start_at >= page.get("total", 0) or not page["issues"]:
break
```
Caveats: `total` can change between pages, so always tolerate an empty page; there is no `isLast` field on this envelope; deep offsets re-scan everything before them.
### Enhanced: `GET|POST /rest/api/3/search/jql` — token paging
The replacement, non-deprecated. Request body/params: `jql`, `nextPageToken`, `maxResults` (default 50, ceiling 5,000 — though real-world pages often cap near 100 even when more are requested, so follow the token instead of assuming page sizes), `fields` (**default is `id` only**, unlike every other endpoint), `expand`, `properties`, `fieldsByKeys`, `failFast`, `reconcileIssues`.
Response envelope (`SearchAndReconcileResults`):
```json
{
"isLast": false,
"issues": [{"id": "10002", "key": "ED-1"}],
"nextPageToken": "CAEaAggB",
"warnings": []
}
```
Key differences from legacy:
| Aspect | Legacy `/search` | Enhanced `/search/jql` |
|--------|------------------|------------------------|
| Offset param | `startAt` | none — opaque `nextPageToken` |
| Total count | `total` present | absent |
| Last-page signal | none (compute from total) | `isLast` boolean; `nextPageToken` omitted on final page |
| Default fields | all navigable | `id` only — pass explicit `fields` |
| Warnings key | `warningMessages` | `warnings` |
| JQL restriction | unbounded allowed | **bounded queries required** — bare `order by key desc` returns 400 |
| `orderBy` cap | none | max 7 fields |
| Consistency | immediate-ish | eventual; optional `reconcileIssues` (≤50 ids) for read-after-write |
"Bounded" means at least one real condition: `assignee = currentUser() order by key` is bounded; `order by created DESC` alone is not.
Loop shape:
```python
body = {"jql": jql, "maxResults": 100, "fields": ["summary", "status"]}
while True:
page = post("/search/jql", json_data=body)
yield from page["issues"]
if page.get("isLast") or "nextPageToken" not in page:
break
body["nextPageToken"] = page["nextPageToken"]
```
Token continuation is sequential-only: you cannot fetch pages in parallel, and you must carry the exact previous token forward.
### Which model fails how — symptoms of mixing them up
- Passing `startAt` to `/search/jql`: parameter ignored/rejected; you silently loop over page one forever if your loop advances the offset instead of the token.
- Reading `total` off `/search/jql`: `KeyError` — the field does not exist; use `/search/approximate-count` first if you need a count.
- Expecting populated `fields` from `/search/jql` without asking: you get `id`/`key` only.
- Sending an unbounded query to `/search/jql`: immediate `400`.
- Calling legacy `/search` after removal completes: connection-level failure/404-class errors; before that, responses still work but the endpoint is formally dead-ended.
### Approximate counts
Need "how many?" without fetching? `POST /rest/api/3/search/approximate-count` with body `{"jql": "project = HSP"}` returns `{"count": 153}`. Works regardless of which search endpoint you use for rows; approximate because it skips permission filtering per row.
## Endpoint Cheat Sheet
| Operation | Call |
|-----------|------|
| Current user | `GET /rest/api/3/myself``{accountId, displayName, emailAddress?, timeZone}` |
| Search (legacy) | `GET/POST /rest/api/3/search` — deprecated, offset paging |
| Search (current) | `GET/POST /rest/api/3/search/jql` — token paging |
| Count matches | `POST /rest/api/3/search/approximate-count` |
| Get issue | `GET /rest/api/3/issue/{key}?fields=summary,status,...` |
| Create issue | `POST /rest/api/3/issue``{fields: {...}}` |
| Edit issue | `PUT /rest/api/3/issue/{key}` — fields at top level, `notifyUsers=false` to silence mail |
| Delete issue | `DELETE /rest/api/3/issue/{key}?deleteSubtasks=true` |
| List transitions | `GET /rest/api/3/issue/{key}/transitions` |
| Apply transition | `POST /rest/api/3/issue/{key}/transitions``{"transition": {"id": "..."}}` |
| Add comment | `POST /rest/api/3/issue/{key}/comment` — ADF body |
| Projects | `GET /rest/api/3/project/search` (paginated; plain `/project` is a deprecated bare array) |
## Sources
- REST API v3 intro (auth modes, error collection): https://developer.atlassian.com/cloud/jira/platform/rest/v3/intro/
- Issue search group (both endpoints, approximate-count): https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issue-search/
- Basic auth for REST APIs: https://developer.atlassian.com/cloud/jira/platform/basic-auth-for-rest-apis/
- Deprecation changelog entry CHANGE-2046: https://developer.atlassian.com/changelog/#CHANGE-2046
- Rate limiting: https://developer.atlassian.com/cloud/jira/platform/rate-limiting/
- Manage API tokens: https://support.atlassian.com/atlassian-account/docs/manage-api-tokens-for-your-atlassian-account/
- OAuth 2.0 (3LO) apps: https://developer.atlassian.com/cloud/jira/platform/oauth-2-3lo-apps/
- PATs (Data Center/Server only): https://confluence.atlassian.com/enterprise/using-personal-access-tokens-1026032365.html
@@ -0,0 +1,214 @@
# Jira Cloud REST API v3 — Issues, Transitions & ADF
Field-level semantics for the issue lifecycle: reading, creating, editing, commenting, and — the part everyone gets wrong — transitioning. Plus the Atlassian Document Format rules that decide whether your payload is accepted at all.
## Reading Issues
`GET /rest/api/3/issue/{issueIdOrKey}`
- Query params: `fields`, `fieldsByKeys`, `expand`, `properties`, `updateHistory`, `failFast`.
- Default response includes all navigable fields. Trim with `?fields=summary,status,assignee` — faster pages, less noise.
- Non-matching keys get a case-insensitive lookup plus moved-issue check; a found match returns directly (no redirect).
- Response top level: `{id, key, self, fields:{...}}`.
Field access patterns:
```json
{
"fields": {
"summary": "Main order flow broken",
"status": {"name": "In Progress", "statusCategory": {"key": "in-flight"}},
"issuetype": {"name": "Bug"},
"priority": {"name": "High"},
"assignee": {"accountId": "5b10a2844c20165700ede21g", "displayName": "Mia Krystof"},
"reporter": {"accountId": "...", "displayName": "..."},
"created": "2019-04-05T10:30:00.000+1000",
"updated": "2024-01-11T08:15:00.000+0000",
"description": {"type": "doc", "version": 1, "content": []}
}
}
```
Gotchas:
- `assignee`/`reporter` may be `null` (unassigned) — null-check before `.displayName`.
- `description` is an ADF object, not text (see ADF section).
- User identity is `accountId` everywhere; `username`/`userKey` were removed in the GDPR migration (April 2019). Email visibility depends on each user's privacy settings.
## Creating Issues
`POST /rest/api/3/issue` with body root keys `fields`, `update`, `historyMetadata`, `properties`, `transition`. Only `fields` matters for basic creation.
```json
{
"fields": {
"project": { "key": "EX" },
"summary": "Order entry fails when selecting supplier.",
"issuetype": { "name": "Bug" },
"description": {
"type": "doc", "version": 1,
"content": [ { "type": "paragraph", "content": [ { "type": "text", "text": "Steps to reproduce..." } ] } ]
},
"priority": { "name": "High" },
"labels": ["bugfix"],
"parent": { "key": "PROJ-123" }
}
}
```
Rules:
- Project by `{"key": ...}` or `{"id": ...}`; issuetype by name or id.
- `description`, `environment`, and any `textarea`-type custom fields **require ADF objects**; plain strings are rejected. Single-line `textfield` custom fields take plain strings.
- Users are addressed as `assignee: {"accountId": "..."}` or `{"id": "<accountId>"}`.
- Success: 201 with `{id, key, self}` (+ optional transition echo).
- Failure: 400/422 with the error collection; `errors` map names the offending field ("Project 'XYZ' does not exist or you do not have permission...").
## Editing Issues
`PUT /rest/api/3/issue/{issueIdOrKey}`
```json
{ "fields": { "summary": "Completed orders still displaying in pending",
"labels": ["bugfix", "triage"] } }
```
- Fields sit under `fields` (or granular ops under `update`, e.g. array field manipulation).
- Success is **204 No Content** — an empty response body means it worked; don't parse for confirmation JSON.
- Transitions are ignored on this endpoint; changing status requires the transitions endpoint below.
- Suppress notification emails with query param `notifyUsers=false` (bulk edits especially).
Deleting: `DELETE /rest/api/3/issue/{key}` refuses when subtasks exist unless you pass `deleteSubtasks=true`; success is 204.
## Comments
`GET /rest/api/3/issue/{key}/comment?startAt=0&maxResults=50``{comments: [...], startAt, maxResults, total}` (offset paging, like legacy search).
Each comment: `{id, author:{accountId, displayName}, body: <ADF>, created, updated, updateAuthor}`.
Add one: `POST /rest/api/3/issue/{key}/comment` with `{"body": {ADF doc}}`. The body must be an ADF object — a bare string fails with a 400/500-class error naming the wrong type.
## Transitions: GET First, Then POST
This is the highest-friction endpoint pair in Jira integration work. Two calls are always required because **transition IDs differ per workflow, per project, and per current status**, and names alone are ambiguous across workflows.
### Step 1 — discover available transitions
`GET /rest/api/3/issue/{key}/transitions?expand=transitions.fields`
```json
{
"transitions": [
{
"id": "31",
"name": "Done",
"hasScreen": true,
"isGlobal": false,
"isConditional": false,
"to": { "name": "Done", "statusCategory": {"key": "completed"} },
"fields": {
"resolution": { "required": true, "allowedValues": [{"name": "Done"}, {"name": "Fixed"}] },
"comment": { "required": false }
}
}
]
}
```
Reading this shape:
- `id` is the string you POST back. Never hardcode it across projects.
- `to.statusCategory.key` (`to-do` / `in-flight` / `completed`) is the stable way to find "the Done-ish transition" without knowing its display name.
- With `expand=transitions.fields`, `fields` lists what the target screen demands and each field's `required` flag plus `allowedValues`.
- Asking for a nonexistent or status-invalid transition yields an **empty list**, not an error.
### Step 2 — apply the transition
`POST /rest/api/3/issue/{key}/transitions`
Minimal payload:
```json
{ "transition": { "id": "31" } }
```
Setting fields during the move (resolution, assignee, comments ride along):
```json
{
"transition": { "id": "31" },
"fields": { "resolution": { "name": "Fixed" } },
"update": {
"comment": [ { "add": { "body": { "type": "doc", "version": 1,
"content": [ { "type": "paragraph", "content": [ { "type": "text", "text": "Shipped in build 47" } ] } ] } } } ]
}
}
```
Semantics that bite:
- Success is **204 No Content**.
- If the target screen marks a field `required: true` and you omit it — classically `resolution` on a Done transition — you get **400** with `"errors": {"resolution": "..."}` naming the missing field. Pre-read the fields map from step 1 instead of guessing.
- `resolution` values come from `allowedValues`; sending `{"name": "Done"}` where the site expects `"Fixed"` fails validation.
- Transition names repeat across workflows; IDs do not. Resolve by ID after discovery, optionally filtering by `to.statusCategory.key`.
### Worked recipe: bulk-close stalled sprint issues
```python
# 1) find candidates (legacy search shown; see rest-auth-and-search.md for token paging)
issues = search('sprint in openSprints() AND updated < -14d AND resolution = Unresolved')
# 2) per issue: discover + apply
for issue in issues:
trans = get(f"/issue/{issue['key']}/transitions")["transitions"]
done = next(t for t in trans if t["to"]["statusCategory"]["key"] == "completed")
post(f"/issue/{issue['key']}/transitions",
json={"transition": {"id": done["id"]},
"fields": {"resolution": {"name": "Done"}}})
```
Rate-limit note: writes count toward per-issue windows (20 per 2s) — sleep briefly between issues in loops.
## Atlassian Document Format (ADF)
The document model for every rich-text field in v3 payloads: issue `description`/`environment`, comment bodies, textarea custom fields. Plain-text strings are rejected for these fields.
### Minimal document
```json
{ "type": "doc", "version": 1,
"content": [
{ "type": "paragraph",
"content": [ { "type": "text", "text": "Hello world" } ] }
] }
```
Structure invariants: exactly one root `doc` with `version: 1`; content is an ordered tree of block nodes (`paragraph`, `heading`, `bulletList`/`orderedList` > `listItem` > `paragraph`, `codeBlock`, `panel`, `table`, `blockquote`); inline content is `text` nodes carrying optional `marks`.
### Formatting quick reference
| Effect | Node/mark shape |
|--------|-----------------|
| Bold | `{"type":"text","text":"world","marks":[{"type":"strong"}]}` |
| Italic | `marks: [{"type":"em"}]` |
| Code | `marks: [{"type":"code"}]` |
| Link | `marks: [{"type":"link","attrs":{"href":"https://..."}}]` |
| Bullet list | `bulletList` node whose `listItem`s contain paragraphs |
| Mention | inline node `{"type":"mention","attrs":{"id":"<accountId>"}}` |
### Practical guidance
- Building from user input? Wrap each line/paragraph as its own `paragraph` node; escape nothing manually — text goes in the `text` property verbatim.
- Extracting? Walk `content[]` recursively collecting `text` nodes' `text` values joined by newlines (the bundled CLI's `view` does this for descriptions).
- Round-tripping rich content through plain text loses formatting permanently; if fidelity matters, fetch the ADF and re-post the same structure.
## Sources
- Issues group (get/create/edit/delete, transitions GET+POST): https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issues/
- Issue comments group: https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issue-comments/
- Issue links group (link payload shapes): https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issue-links/
- Projects group (paginated vs deprecated bare-array): https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-projects/
- Myself resource: https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-myself/
- REST v3 intro (error collection schema): https://developer.atlassian.com/cloud/jira/platform/rest/v3/intro/
- ADF structure reference: https://developer.atlassian.com/cloud/jira/platform/apis/document/structure/
- GDPR accountId migration guide: https://developer.atlassian.com/cloud/jira/platform/deprecation-notice-user-privacy-api-migration-guide/