Skip to main content
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 and have the client check every response against its own schemas.

At a glance

Start

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, [email protected] (Site Admin), whose API token is jira_test_api_token. Point jira.js at the emulator with host:

URL mapping

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, 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:

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.
  • 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). 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

Users get a stable account id derived from their email unless the seed sets account_id. [email protected] (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

See CONFORMANCE.md for every provider.