> ## Documentation Index
> Fetch the complete documentation index at: https://docs.deploybaseline.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Jira

> The REST API, JQL search, Agile boards and sprints, OAuth 2.0 (3LO), and webhooks.

Stateful Jira Cloud REST API emulation: users, projects, workflows, issues, transitions, comments, watchers, the changelog, custom fields, JQL search, Agile boards and sprints, signed admin webhooks, OAuth 2.0 (3LO) sign-in, and app webhooks. The tests drive it with [jira.js](https://github.com/MrRefactoring/jira.js) and have the client check every response against its own schemas.

## At a glance

| | |
| - | - |
| Package | `@deploybaseline/jira` |
| Port | `4000` when started alone; named services take consecutive ports |
| Config key | `jira` |
| HTTP operations | 138, browsable at `http://localhost:3999/docs/jira` while it runs ([API reference](/api-reference.md)) |

```bash CLI theme={null}
npx @deploybaseline/cli start --service jira
```

```ts Programmatic theme={null}
import { createEmulator } from "@deploybaseline/cli";

const jira = await createEmulator({ service: "jira" });
// jira.url is http://localhost:4000
```

```json baseline.config.json theme={null}
{
  "jira": {
    "site": {
      "name": "Acme"
    },
    "users": [
      {
        "email": "admin@example.com",
        "display_name": "Site Admin",
        "admin": true,
        "api_token": "jira_test_api_token"
      },
      {
        "email": "dev@example.com",
        "display_name": "Developer",
        "api_token": "jira_test_api_token"
      }
    ],
    "custom_fields": [
      {
        "name": "Story Points",
        "type": "number"
      }
    ],
    "projects": [
      {
        "key": "ENG",
        "name": "Engineering",
        "lead": "admin@example.com"
      }
    ],
    "issues": [
      {
        "project": "ENG",
        "type": "Bug",
        "summary": "Checkout fails on an empty cart",
        "description": "Reproduce with an empty cart and press Pay.",
        "assignee": "dev@example.com",
        "labels": ["checkout"]
      }
    ],
    "oauth_apps": [
      {
        "name": "Local app",
        "client_id": "jira-client-id",
        "client_secret": "jira-client-secret",
        "redirect_uris": ["http://localhost:3000/api/auth/callback/atlassian"],
        "scopes": [
          "read:jira-work",
          "write:jira-work",
          "read:jira-user",
          "read:me",
          "manage:jira-webhook"
        ]
      }
    ],
    "boards": [
      {
        "name": "ENG board",
        "project": "ENG",
        "sprints": [
          {
            "name": "Sprint 1",
            "state": "active",
            "start_date": "2026-01-05",
            "end_date": "2026-01-19",
            "issues": ["ENG-1"]
          }
        ]
      }
    ]
  }
}
```

## Start

```bash theme={null}
npx @deploybaseline/cli start --service jira
```

Jira starts only when named in config or with `--service`. When it is the only enabled service, it listens on `http://localhost:4000`.

Without seed config, the site has one admin account, `admin@example.com` (Site Admin), whose API token is `jira_test_api_token`.

Point jira.js at the emulator with `host`:

```ts theme={null}
import { createCloudClient } from "jira.js";

const jira = createCloudClient({
  host: process.env.JIRA_EMULATOR_URL!,
  auth: { type: "basic", email: "admin@example.com", apiToken: "jira_test_api_token" },
});

const issue = await jira.issues.createIssue({
  fields: { project: { key: "ENG" }, issuetype: { name: "Task" }, summary: "Try the emulator" },
});
```

## URL mapping

| Real Jira URL | Emulator URL |
| - | - |
| `https://your-site.atlassian.net/rest/api/3/...` | `$JIRA_EMULATOR_URL/rest/api/3/...` |
| `https://your-site.atlassian.net/rest/api/2/...` | `$JIRA_EMULATOR_URL/rest/api/2/...` |
| `https://your-site.atlassian.net/rest/webhooks/1.0/...` | `$JIRA_EMULATOR_URL/rest/webhooks/1.0/...` |
| `https://api.atlassian.com/ex/jira/{cloudId}/rest/api/3/...` | `$JIRA_EMULATOR_URL/ex/jira/{cloudId}/rest/api/3/...` |
| `https://auth.atlassian.com/authorize` | `$JIRA_EMULATOR_URL/authorize` |
| `https://auth.atlassian.com/oauth/token` | `$JIRA_EMULATOR_URL/oauth/token` |
| `https://api.atlassian.com/oauth/token/accessible-resources` | `$JIRA_EMULATOR_URL/oauth/token/accessible-resources` |
| `https://api.atlassian.com/me` | `$JIRA_EMULATOR_URL/me` |

## Auth

* **Basic**: an account's email and API token, as Jira Cloud takes them. Seeded accounts use `jira_test_api_token` unless the seed sets `api_token`. Wrong credentials or a deactivated account get HTTP 401 with `X-Seraph-LoginReason: AUTHENTICATED_FAILED`, which jira.js reports as an `AuthError`.
* **OAuth 2.0 (3LO)**: an access token from the [authorization code flow](#oauth-2-0-3lo), accepted only through the gateway path and checked against the operation's scopes.
* **Bearer**: a token the CLI issued. The default token (and any token the CLI does not know) acts as the first site admin; a token whose login is an account id or email acts as that account.

Requests with no credentials get HTTP 401. `GET /rest/api/3/serverInfo` needs none.

Site admins (`admin: true`) manage projects and admin webhooks; other accounts get HTTP 403 there. Every account can browse, create, edit, and transition every issue. Comments can be edited or deleted by their author or an admin.

## OAuth 2.0 (3LO)

Apps that act for a user sign in the way Atlassian documents: send the user to `/authorize`, exchange the code at `/oauth/token`, find the site's cloud id with `/oauth/token/accessible-resources`, and call `/ex/jira/{cloudId}/...` with the access token.

* `GET /authorize` takes `audience=api.atlassian.com`, `client_id`, `scope`, `redirect_uri`, `state`, and `response_type=code`, and shows a consent screen listing the scopes and the site's active accounts. Choosing an account redirects to `redirect_uri` with `code` and `state`; Cancel redirects with `error=access_denied`. An unknown app or callback URL gets an error page. Other problems redirect with `error` and `error_description`: `Service not found: <audience>` for a wrong audience, `unsupported_response_type`, and `invalid_scope` for an unknown scope or one the app was not given.
* `POST /oauth/token` takes JSON (as Atlassian documents) or a form, with the client credentials in the body or as Basic auth. `grant_type=authorization_code` takes `code` and the same `redirect_uri`; codes last ten minutes and work once. `grant_type=refresh_token` takes `refresh_token`. Responses are `{ access_token, expires_in: 3600, token_type: "Bearer", scope }`, plus `refresh_token` when `offline_access` was granted. Wrong client credentials get HTTP 401 `{ "error": "access_denied", "error_description": "Unauthorized" }`; a bad code gets HTTP 403 `invalid_grant`.
* Refresh tokens rotate: each refresh returns a new one. The one sent may be replayed for ten minutes; replaying it later gets HTTP 403 `{ "error": "invalid_grant", "error_description": "Unknown or invalid refresh token." }` and revokes every token from that sign-in. Refresh tokens expire after 90 days without use.
* `GET /oauth/token/accessible-resources` returns the site, `[{ id, name, url, scopes, avatarUrl }]`, where `id` is the cloud id and `url` is the emulator's address. `GET /me` needs `read:me` and returns the user's `account_id`, `email`, `name`, `picture`, `account_status`, `nickname`, `zoneinfo`, `locale`, and `extended_profile`.
* Access tokens are JWTs that last an hour. They work only through `/ex/jira/{cloudId}`; on the site's own paths they get HTTP 401 with `X-Seraph-LoginReason`. An expired, revoked, or forged token gets HTTP 401 `{ "code": 401, "message": "Unauthorized" }`, which makes jira.js refresh and retry.
* Each operation takes its classic scope or its main granular scope, and a token without either gets HTTP 401 `{ "code": 401, "message": "Unauthorized; scope does not match" }`, which jira.js reports as a `ScopeError`. Reads take `read:jira-work` (`read:issue:jira`, `read:project:jira`, `read:filter:jira`, and so on), writes take `write:jira-work`, users and `myself` take `read:jira-user`, project changes take `manage:jira-project`, and app webhooks take `manage:jira-webhook`. The Agile API takes only Jira Software scopes such as `read:board-scope:jira-software`, `write:sprint:jira-software`, and `read:epic:jira-software`. Admin webhooks take no scope, so apps cannot use them.
* One grant exists per app per account; consenting again replaces its scopes.

Seed apps under `oauth_apps` to check client ids, callback URLs, and scopes the way the developer console does. With no apps seeded, any client id is accepted with any scope and callback URL, and the app is registered with the secret it sends on its first token exchange.

jira.js reaches `auth.atlassian.com` and `api.atlassian.com` by fixed URLs, so give it a `fetch` that sends them to the emulator:

```ts theme={null}
import { createCloudClient, exchangeAuthorizationCode, generateAuthorizationUrl } from "jira.js";

const emulator = process.env.JIRA_EMULATOR_URL!;
const toEmulator: typeof fetch = (input, init) =>
  fetch(String(input instanceof Request ? input.url : input).replace(/^https:\/\/(auth|api)\.atlassian\.com/, emulator), init);

// Send the user here, with the host swapped for the emulator's.
const authorizeUrl = generateAuthorizationUrl({
  clientId: "jira-client-id",
  scopes: ["read:jira-work", "write:jira-work", "offline_access"],
  redirectUri: "http://localhost:3000/api/auth/callback/atlassian",
  state,
}).replace("https://auth.atlassian.com", emulator);

// In the callback:
const tokens = await exchangeAuthorizationCode({
  clientId: "jira-client-id",
  clientSecret: "jira-client-secret",
  code,
  redirectUri: "http://localhost:3000/api/auth/callback/atlassian",
  fetch: toEmulator,
});

const jira = createCloudClient({
  auth: {
    type: "oauth2",
    accessToken: tokens.accessToken,
    refreshToken: tokens.refreshToken,
    clientId: "jira-client-id",
    clientSecret: "jira-client-secret",
  },
  fetch: toEmulator,
});
```

## REST API

### Issues

* `POST /rest/api/3/issue`, `POST /rest/api/3/issue/bulk` (up to 50; failures listed by `failedElementNumber`)
* `GET /rest/api/3/issue/{issueIdOrKey}` with `fields` (`*all`, `*navigable`, names, `-name`) and `expand` (`names`, `renderedFields`, `transitions`, `changelog`)
* `PUT /rest/api/3/issue/{issueIdOrKey}` with `fields` and `update` operations (`set`; `add` and `remove` for labels and multi-select fields; `add` for comments) and `returnIssue=true`
* `DELETE /rest/api/3/issue/{issueIdOrKey}` (an issue with subtasks needs `deleteSubtasks=true`)
* `PUT /rest/api/3/issue/{issueIdOrKey}/assignee` (`accountId: null` unassigns, `"-1"` picks the project default)
* `GET /rest/api/3/issue/createmeta/{projectIdOrKey}/issuetypes` and `.../issuetypes/{issueTypeId}`
* `GET /rest/api/3/issue/{issueIdOrKey}/editmeta`
* `GET`/`POST /rest/api/3/issue/{issueIdOrKey}/transitions`
* `GET /rest/api/3/issue/{issueIdOrKey}/changelog`
* `GET`/`POST /rest/api/3/issue/{issueIdOrKey}/comment`, `GET`/`PUT`/`DELETE .../comment/{id}`
* `GET`/`POST`/`DELETE /rest/api/3/issue/{issueIdOrKey}/watchers`

Issue keys count up per project and are never reused, even after a delete or a restart. Rich text (`description`, `environment`, comment bodies) must be an Atlassian document on v3; anything else fails with `Operation value must be an Atlassian Document`.

Settable fields are `summary`, `description`, `environment`, `issuetype`, `priority`, `assignee`, `reporter`, `labels`, `duedate`, `parent`, `resolution`, and custom fields. Subtasks need a parent in the same project; standard issues may have an epic as parent. Setting anything else fails with `Field '<id>' cannot be set. It is not on the appropriate screen, or unknown.`

Edits and transitions record changelog items (`field`, `fieldId`, `from`, `fromString`, `to`, `toString`) for every field they change.

### Workflows and transitions

New projects use Jira's simplified workflow: To Do, In Progress, and Done, each reachable from any status through a global transition (`11`, `21`, `31`). Seeded workflows can limit transitions to certain statuses. Moving into a status in the `done` category sets the resolution (`Done` unless the transition sets one) and `resolutiondate`; moving out clears both.

### Projects

* `GET /rest/api/3/project`, `GET /rest/api/3/project/search` (`query`, `keys`, `id`, `typeKey`, `orderBy`)
* `POST /rest/api/3/project` (admins; returns `{ self, id, key }` with a numeric `id`)
* `GET`/`PUT`/`DELETE /rest/api/3/project/{projectIdOrKey}` (changing a project key is not supported)
* `GET /rest/api/3/project/{projectIdOrKey}/statuses`

### Users and metadata

* `GET /rest/api/3/myself`, `GET /rest/api/3/user`, `GET /rest/api/3/user/search`, `GET /rest/api/3/users`, `GET /rest/api/3/users/search`, `GET /rest/api/3/user/assignable/search`
* `GET /rest/api/3/serverInfo`
* `GET /rest/api/3/status`, `GET /rest/api/3/status/{idOrName}`, `GET /rest/api/3/statuscategory`, `GET /rest/api/3/statuscategory/{idOrKey}`
* `GET /rest/api/3/issuetype`, `GET /rest/api/3/issuetype/{id}`, `GET /rest/api/3/issuetype/project`
* `GET /rest/api/3/priority`, `GET /rest/api/3/priority/search`, `GET /rest/api/3/priority/{id}`
* `GET /rest/api/3/resolution`, `GET /rest/api/3/resolution/search`, `GET /rest/api/3/resolution/{id}`
* `GET /rest/api/3/field`

Every site has the issue types Epic, Story, Task, Bug, and Subtask, the priorities Highest to Lowest (Medium by default), and the resolutions Done, Won't Do, Duplicate, and Cannot Reproduce.

### REST API v2

Every `/rest/api/2/...` path answers like its `/rest/api/3/...` twin, except that rich text is a string: strings sent there become one paragraph per line, and documents read back come out as their plain text. jira.js sends requests there when given wiki markup strings, so both styles work against the emulator.

### Atlassian gateway

`/ex/jira/{cloudId}/...` serves the same API for the site's cloud id, the way OAuth 2.0 apps call Jira through `api.atlassian.com`. It is the only place OAuth access tokens work. Any other cloud id gets HTTP 404. Set the cloud id with `site.cloud_id`; the default is `11111111-2222-3333-4444-555555555555`.

### Errors

Errors use Jira's body, `{ "errorMessages": [...], "errors": { "<field>": "..." } }`, with Jira's messages: for example `Issue does not exist or you do not have permission to see it.` with HTTP 404, and `You must specify a summary of the issue.` under `errors.summary` with HTTP 400.

## JQL search

* `GET`/`POST /rest/api/3/search/jql` with `jql`, `maxResults`, `nextPageToken`, `fields`, and `expand`. Issues come back as `{ id }` unless `fields` asks for more (`*all`, `*navigable`, names, `-name`). Pages carry `nextPageToken` until `isLast` is true; a token only works with the query that made it. `maxResults` defaults to 50 and is capped at 5000 when only `id` or `key` is asked for, 100 otherwise.
* `POST /rest/api/3/search/approximate-count` returns `{ count }`.
* `POST /rest/api/3/jql/match` checks up to 50 issue ids against up to 10 queries.
* `POST /rest/api/3/jql/parse?validation=strict|warn|none` returns each query's structure, with errors (`strict`) or warnings (`warn`) for unknown fields, values, and functions.
* `GET`/`POST /rest/api/3/jql/autocompletedata` and `GET /rest/api/3/jql/autocompletedata/suggestions` (`fieldName`, `fieldValue`) list fields, operators, functions, and values.
* `GET /rest/api/3/issue/picker` (`query`, `currentJQL`, `currentIssueKey`, `currentProjectId`, `showSubTasks`, `showSubTaskParent`) suggests issues by key or summary words in one `Current Search` section.

The old `GET`/`POST /rest/api/3/search` answers HTTP 410 with Jira's message pointing at `/search/jql`, as Jira Cloud has since 2025. Like Jira, the search endpoints refuse a query with no restriction (`Unbounded JQL queries are not allowed here. Please add a search restriction to your query.`).

The parser follows Atlassian's grammar: `AND`, `OR`, `NOT` (or `!`), parentheses, quoted strings with escapes, `cf[10016]` and custom field names, `ORDER BY` with `ASC` and `DESC`. Syntax errors use Jira's wording and position, such as `Error in the JQL Query: Expecting either a value, list or function but got 'EOF'. (line 1, character 11)`.

| Field | Operators |
| - | - |
| `project`, `issuetype` (`type`), `statusCategory` | `=`, `!=`, `IN`, `NOT IN` |
| `issuekey` (`key`, `id`) | also `>`, `>=`, `<`, `<=` within a project |
| `status` | also `WAS`, `CHANGED` |
| `priority` | also `IS`, comparisons by rank (`priority > Medium`), `WAS`, `CHANGED` |
| `resolution` (`Unresolved` means empty), `assignee`, `reporter` | also `IS`, `WAS`, `CHANGED` |
| `creator`, `watcher`, `labels`, `parent` | `=`, `!=`, `IN`, `NOT IN`, `IS` (not `creator`) |
| `summary`, `description`, `environment`, `comment`, `text` | `~`, `!~`, `IS` |
| `created`, `updated`, `resolved`, `due`, `statusCategoryChangedDate` | `=`, `!=`, `IN`, `NOT IN`, comparisons, `IS` (not `created` or `updated`) |
| Custom fields | by type: numbers and dates compare, text uses `~`, select, user, and labels fields take `=` and `IN` |

Values name things the way Jira does: projects by key, name, or id; users by account id, email, or display name; statuses, types, priorities, and resolutions by name or id. Dates take `yyyy-MM-dd`, `yyyy/MM/dd`, either with `HH:mm`, or periods like `-5d` and `4w 2d`, all in UTC. Functions: `currentUser()`, `now()`, `startOfDay()`, `endOfDay()`, `startOfWeek()`, `endOfWeek()`, `startOfMonth()`, `endOfMonth()`, `startOfYear()`, `endOfYear()` (each with an optional increment such as `-1w` or `+2d`), `standardIssueTypes()`, and `subTaskIssueTypes()`.

As in Jira, `!=` and `NOT IN` never match an empty field: `assignee != currentUser()` skips unassigned issues. `~` matches every word on a light English stem, `"exact phrases"`, `term*` and `te?t` wildcards, and `-excluded` words. `WAS` and `CHANGED` read the changelog and take `AFTER`, `BEFORE`, `ON`, `DURING`, and `BY` (`CHANGED` also takes `FROM` and `TO`). Without `ORDER BY`, results are newest first; empty values sort last ascending and first descending. Unknown fields, values, and functions fail with Jira's messages, for example `Field 'flavor' does not exist or you do not have permission to view it.` and `The value 'Shipped' does not exist for the field 'status'.`

## Agile boards and sprints

The Jira Software API lives under `/rest/agile/1.0`. Issue lists answer there with `startAt` paging and at the same path under `/rest/software/1.0` with `nextPageToken` paging, as jira.js 6 calls them. Lists take `jql` (its `ORDER BY` replaces the default rank order), `validateQuery`, `fields`, and `expand`, and return every navigable field plus the Software fields `sprint`, `closedSprints`, `flagged`, and `epic` unless `fields` narrows them.

* Boards: `GET`/`POST /rest/agile/1.0/board` (`name`, `type`, `projectKeyOrId`, `filterId`), `GET /board/filter/{filterId}`, `GET`/`DELETE /board/{boardId}`, `GET .../configuration` (columns per status category, estimation field, rank field), `GET .../project`
* Board issues: `GET .../issue`, `GET .../backlog`, `GET .../sprint/{sprintId}/issue`, `GET .../epic/{epicId}/issue`, `GET .../epic/none/issue`, and `GET /rest/software/1.0/board/{boardId}/issue/approximate-count` and `.../backlog/approximate-count`
* Sprints: `GET /board/{boardId}/sprint` (`state=future,active,closed`), `POST /rest/agile/1.0/sprint`, `GET`/`PUT`/`POST`/`DELETE /sprint/{sprintId}`, `POST .../issue` (move and rank), `GET .../issue`, `POST .../swap`
* Backlog and rank: `POST /rest/agile/1.0/backlog/issue`, `POST /backlog/{boardId}/issue`, `PUT /rest/agile/1.0/issue/rank` (`rankBeforeIssue` or `rankAfterIssue`; 207 with per-issue `entries` when some fail)
* Issues: `GET /rest/agile/1.0/issue/{issueIdOrKey}`, `GET`/`PUT .../estimation?boardId=`
* Epics: `GET`/`POST /rest/agile/1.0/epic/{epicIdOrKey}` (`summary`, `color`, `done`), `GET`/`POST .../issue`, `GET`/`POST /rest/agile/1.0/epic/none/issue`

A board shows the issues its saved filter finds, in statuses mapped to its columns; scrum boards leave epics out. The backlog holds a scrum board's incomplete issues (not in the last column) that are in no active or future sprint. Kanban boards have no sprints or backlog and answer `The board does not support sprints`.

Sprints follow Jira's lifecycle. New sprints are `future`. Setting `state: "active"` needs a start and end date and no other active sprint on the board; setting `state: "closed"` sets `completeDate`, and closed sprints cannot change. `PUT` replaces the sprint, clearing what the body leaves out; `POST` changes only what it names. Sprint names are at most 30 characters.

Membership lives in each issue's Sprint field (`customfield_10020`): closed sprints stay as history and an issue is in at most one active or future sprint, so completing a sprint drops its unfinished issues back to the backlog while `sprint in closedSprints()` still finds them. The field can also be set on issue create and edit with a sprint id (`null` removes it). Every move records a `Sprint` changelog item and sends `jira:issue_updated`.

Rank (`customfield_10019`) orders boards, backlogs, and sprints. New issues go to the bottom; `PUT /issue/rank` and the move endpoints place issues before or after another, changing only the moved issues' ranks and recording `Rank` changelog items (`Ranked higher` or `Ranked lower`). Search with `ORDER BY Rank`. Rank cannot be set through issue fields.

Epics are issues of type Epic; an issue belongs to an epic through its `parent`. Estimation uses the board's number field, Story Points by default when the site has a field named Story Points or Story point estimate.

JQL gains `sprint` (by id or name, `IS EMPTY`) with `openSprints()`, `closedSprints()`, and `futureSprints()`, and `ORDER BY Rank`.

### Saved filters

* `POST /rest/api/3/filter`, `GET`/`PUT`/`DELETE /rest/api/3/filter/{id}`, `GET /rest/api/3/filter/search` (`filterName`, `accountId`, `id`, `orderBy`), `GET /rest/api/3/filter/my` (`includeFavourites`), `GET /rest/api/3/filter/favourite`, `PUT`/`DELETE /rest/api/3/filter/{id}/favourite`

Every account sees every filter; its owner or an admin may change or delete it. Names are unique per owner and the JQL is validated. A filter that a board uses cannot be deleted.

## Webhooks

Admin webhooks are registered with `/rest/webhooks/1.0/webhook` (`GET`, `POST`) and `/rest/webhooks/1.0/webhook/{id}` (`GET`, `PUT`, `DELETE`), or seeded. The body takes `name`, `url`, `events`, `filters`, `excludeBody`, `enabled`, and `secret`. A JQL query under `filters["issue-related-events-section"]` limits issue and comment events to the issues it matches, checked after the change; it is validated when the webhook is saved, and `currentUser()` matches nobody there.

Events sent:

* `jira:issue_created`, `jira:issue_updated`, `jira:issue_deleted`, with `issue_event_type_name` set to `issue_created`, `issue_updated`, `issue_assigned`, `issue_generic`, `issue_resolved`, `issue_reopened`, `issue_commented`, `issue_comment_edited`, `issue_comment_deleted`, or `issue_deleted`. Updates carry the `changelog`; comment events carry the `comment`.
* `comment_created`, `comment_updated`, `comment_deleted`
* `project_created`, `project_updated`, `project_deleted`
* `sprint_created`, `sprint_updated` (with the previous sprint as `oldValue`), `sprint_started`, `sprint_closed`, `sprint_deleted`
* `board_created`, `board_deleted`

Each delivery is a POST with `X-Atlassian-Webhook-Identifier` and `X-Atlassian-Webhook-Flow: Primary`. A webhook with a secret adds `X-Hub-Signature: sha256=<hex HMAC-SHA256 of the body>`, which jira.js checks with `verifyWebhookSignature` from `jira.js/webhooks`. URL variables such as `{issue.key}`, `{issue.id}`, `{project.key}`, `{project.id}`, `{comment.id}`, and `{modifiedUser.accountId}` are filled in. `excludeBody: true` sends an empty body.

### App webhooks

OAuth 2.0 apps register their own webhooks through `/rest/api/3/webhook`, and only apps may: Basic auth and CLI tokens get HTTP 403 `Only Connect and OAuth 2.0 apps can use this operation.`

* `POST /rest/api/3/webhook` takes `url` and `webhooks`, a list of `{ events, jqlFilter, fieldIdsFilter?, issuePropertyKeysFilter? }`, and answers `webhookRegistrationResult` with `createdWebhookId` or `errors` for each entry.
* `GET /rest/api/3/webhook` lists the app's webhooks with `expirationDate`; `DELETE /rest/api/3/webhook` takes `webhookIds` and answers HTTP 202; `PUT /rest/api/3/webhook/refresh` takes `webhookIds` and returns the new `expirationDate`. Ids of other apps' webhooks are ignored.
* `GET /rest/api/3/webhook/failed` lists deliveries that got no 2xx, oldest first, with `maxResults` (up to 100) and `after`, and a `next` link while more remain.

Events are the issue, comment, issue property, sprint, and version events Atlassian lists. `jqlFilter` takes `issueKey`, `project`, `issuetype`, `status`, `priority`, `assignee`, and `reporter` with `=`, `!=`, `IN`, and `NOT IN`, joined by `AND` and `OR`; anything else, and values the site does not have, are refused with a message. `fieldIdsFilter` goes with `jira:issue_updated` and sends only updates that change one of those fields. Sprint and version events ignore the JQL. Webhooks expire 30 days after they are registered or refreshed, and expired ones are still listed so they can be refreshed. An app may register five webhooks per user.

Matching events go out once per app and URL, with every matching webhook in `matchedWebhookIds` and `Authorization: Bearer <JWT>`, signed HS256 with the app's client secret. Atlassian does not document the token's claims; Baseline's are `iss` (the emulator's address), `aud` (the client id), `iat`, `exp` (ten minutes on), and `jti`, so check the signature and expiry rather than particular claims. Webhooks stop when their user removes the app.

## Seed config

```json theme={null}
{
  "jira": {
    "site": {
      "name": "Acme",
      "cloud_id": "11111111-2222-3333-4444-555555555555"
    },
    "users": [
      {
        "email": "admin@example.com",
        "display_name": "Site Admin",
        "admin": true,
        "api_token": "jira_test_api_token"
      },
      {
        "email": "dev@example.com",
        "display_name": "Developer"
      }
    ],
    "statuses": [
      {
        "name": "In Review",
        "category": "indeterminate"
      }
    ],
    "workflows": [
      {
        "name": "Review flow",
        "statuses": ["To Do", "In Progress", "In Review", "Done"],
        "transitions": [
          {
            "id": "11",
            "name": "Start",
            "from": ["To Do"],
            "to": "In Progress"
          },
          {
            "id": "21",
            "name": "Review",
            "from": ["In Progress"],
            "to": "In Review"
          },
          {
            "id": "31",
            "name": "Approve",
            "from": ["In Review"],
            "to": "Done"
          },
          {
            "id": "41",
            "name": "Reopen",
            "to": "To Do"
          }
        ]
      }
    ],
    "custom_fields": [
      {
        "id": "customfield_10016",
        "name": "Story Points",
        "type": "number"
      },
      {
        "name": "Team",
        "type": "select",
        "options": ["Payments", "Search"]
      }
    ],
    "projects": [
      {
        "key": "ENG",
        "name": "Engineering",
        "lead": "admin@example.com",
        "workflow": "Review flow",
        "assignee_type": "UNASSIGNED"
      }
    ],
    "issues": [
      {
        "project": "ENG",
        "type": "Bug",
        "summary": "Checkout fails on an empty cart",
        "description": "Reproduce with an empty cart and press Pay.",
        "status": "In Progress",
        "priority": "High",
        "assignee": "dev@example.com",
        "labels": ["checkout"],
        "fields": {
          "customfield_10016": 3
        },
        "comments": [
          {
            "body": "Seen on Safari too.",
            "author": "dev@example.com"
          }
        ]
      }
    ],
    "webhooks": [
      {
        "name": "Issue sync",
        "url": "http://localhost:3000/api/jira/webhook",
        "events": ["jira:issue_created", "jira:issue_updated", "comment_created"],
        "jql": "project = ENG AND issuetype = Bug",
        "secret": "local-webhook-secret"
      }
    ],
    "oauth_apps": [
      {
        "name": "Local app",
        "client_id": "jira-client-id",
        "client_secret": "jira-client-secret",
        "redirect_uris": ["http://localhost:3000/api/auth/callback/atlassian"],
        "scopes": [
          "read:jira-work",
          "write:jira-work",
          "read:jira-user",
          "read:me",
          "manage:jira-webhook"
        ]
      }
    ],
    "filters": [
      {
        "name": "My open bugs",
        "jql": "assignee = currentUser() AND issuetype = Bug AND resolution = Unresolved",
        "owner": "dev@example.com",
        "favourite": true
      }
    ],
    "boards": [
      {
        "name": "ENG board",
        "project": "ENG",
        "type": "scrum",
        "sprints": [
          {
            "name": "Sprint 1",
            "state": "closed",
            "start_date": "2024-04-01",
            "end_date": "2024-04-14"
          },
          {
            "name": "Sprint 2",
            "state": "active",
            "start_date": "2024-04-15",
            "end_date": "2024-04-28",
            "goal": "Ship saved cards",
            "issues": ["ENG-1"]
          },
          {
            "name": "Sprint 3"
          }
        ]
      },
      {
        "name": "ENG flow",
        "project": "ENG",
        "type": "kanban"
      }
    ]
  }
}
```

Users get a stable account id derived from their email unless the seed sets `account_id`. `admin@example.com` (Site Admin) always exists. A transition without `from` is global. Without `transitions`, a workflow gets a global transition to each of its statuses. Custom field types are `text`, `number`, `date`, `datetime`, `select`, `multiselect`, `user`, and `labels`. A seeded issue's `fields` take values the way the REST API does, and its `description` and comment bodies take plain text or an Atlassian document. A webhook's `jql` becomes its issue filter; invalid JQL stops the seed. Custom fields cannot reuse the Sprint (`customfield_10020`) or Rank (`customfield_10019`) ids, and seeded fields without an `id` count up from `customfield_10030`.

An OAuth app's `scopes` are the ones given in the developer console; `offline_access` needs no entry. Seeding any app turns on client id, callback URL, and scope checks.

Each board gets a filter named `Filter for <board>` with `jql` (default `project = KEY ORDER BY Rank ASC`), or uses a seeded filter named by `filter`. `estimation_field` names a number field (or `null` for none). Sprints list their issues by key; an active or closed sprint needs `start_date` and `end_date`, a board has at most one active sprint, and an issue is in at most one open sprint. Seeded issues are ranked in the order they are listed.

## Inspector

Open the emulator's root URL for tabs listing issues (filter by project with `?project=ENG`), projects and the site's cloud id, boards with their sprints and saved filters, users, admin webhooks with their recent deliveries, and OAuth apps with their grants, app webhooks, and recent app deliveries.

## Limits

* Connect and Forge apps are not modeled. OAuth covers one site, has no PKCE or `prompt` handling, and checks one granular scope per operation where Atlassian lists a set. Users cannot revoke an app except by reset or by editing state.
* App webhooks are not retried: a delivery without a 2xx goes straight to the failed list. Issue property and version events can be registered but are never sent, because neither feature is modeled.
* Boards have no quick filters, swimlanes, board properties, features, reports, versions, or column limits, and boards only live in projects. Filters have no share or edit permissions or subscriptions. Parallel sprints, the `Flagged` field, and epic ranking are not modeled.
* JQL has no `issueHistory()`, `membersOf()`, `linkedIssues()`, or other functions beyond those listed above, no entity property clauses, and dates are in UTC rather than the searcher's time zone.
* Attachments, issue links, components, versions, worklogs, and issue properties are not modeled.
* One permission model applies everywhere: every account browses and edits every issue, and admins administer.
* Admin webhooks are not retried.

## Conformance

| | |
| - | - |
| Routes | 138 supported or partial, 0 planned |
| Route gate | fail |
| Spec gate | no public spec |
| SDKs tested | `jira.js@6.3.0` |
| Dual mode | not yet |

See [CONFORMANCE.md](https://github.com/deploybaseline/cli/blob/main/CONFORMANCE.md) for every provider.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.