Support Time Contract Management for JSM

REST API reference

REST API v1 reference for Support Time Contract Management. All paths are relative to the base URL copied from Connected apps. Configure OAuth 2.0 before using these endpoints.

Every endpoint needs an active app license, App REST APIs enabled on the site, an edition that allows the API, and the matching app scope plus read:forge-app:jira. Advanced has API access; Standard has access only when a rollout has explicitly enabled it for your site.

Endpoints

Operation

Method and path

App scope

Success status

Create a contract

POST /v1/contracts

write:contract:custom

201; 200 for an existing externalId

Import contracts

POST /v1/contracts/import

write:contract:custom

202

Get import status

GET /v1/imports/{batchId}

read:contract:custom

200

List contracts

GET /v1/contracts

read:contract:custom

200

Get a contract

GET /v1/contracts/{id}

read:contract:custom

200

Update a contract

PUT /v1/contracts/{id}

write:contract:custom

200

Delete a contract

DELETE /v1/contracts/{id}

delete:contract:custom

200

Conventions

Topic

Convention

Format

JSON (application/json), UTF-8. Send Accept: application/json; POST and PUT also send Content-Type: application/json.

Identifiers

Path parameters use the app’s opaque contract id (for example c_8f31a0d2), not externalId. Use externalId for idempotent writes and GET /v1/contracts?externalId=… lookups.

Dates

startDate and endDate: YYYY-MM-DD. Timestamps: ISO 8601 UTC with milliseconds.

Hours

Decimal hours in requests and responses; stored internally in seconds.

Money

ratePerHour uses the currency configured in app settings.

People

Atlassian account IDs; use the Jira REST API to look up names.

Response evolution

Ignore unrecognized response fields.

Contract scope

Supply a JQL query (recommended) or the structured alternative. If both are present, jql wins.

Field

Type

Description

scope.jql

string

JQL selecting the issues covered by the contract.

scope.field

string

Structured alternative: a field name such as Organizations, or customfield_XXXXX.

scope.fieldValue

string

Structured alternative: the value the field must equal.

scope.projectKeys

array of strings

Structured alternative: restrict to these JSM project keys.

Hours and lifecycle

The app calculates loggedHours from Jira worklogs. It saves a new contract immediately with CALCULATING and loggedHours: 0, then computes the eligible billable hours in the background. Poll GET /v1/contracts/{id} until computation finishes. Do not treat remaining hours as final while computation is in progress.

Status

Meaning

CALCULATING

Saved; hours are still being computed.

ACTIVE

Computed and within the applicable hours/date window.

EXPIRED

Computed and reached the hours allowance and/or end date, according to the contract type.

FAILED

Computation could not finish. Narrow or fix the scope and re-save with PUT.

Worklogs are matched by their started date against the contract window, not their creation date. Historical worklogs count if their original started dates and issue scope match. If worklogs cannot be migrated, use consumedHours as an opening balance.

Do not migrate historical worklogs and seed consumedHours for the same used time: it double-counts.

Example data

Examples use the fictional Acme Support Desk project SUP and Globex organization 10042. Replace those values with your own Jira data. Response IDs and timestamps are illustrative. Each example starts from the stated sample data rather than forming one sequence of changes.

Bash
export BASE="YOUR-COMPLETE-BASE-URL"
# Supply ACCESS_TOKEN from your integration’s secure token store.

Create a contract

POST /v1/contracts

Required app scope: write:contract:custom. Creates one contract and computes its hours asynchronously.

Body field

Type

Required

Description

externalId

string

Recommended

Your contract identifier. Matching an existing externalId updates that contract instead of creating a duplicate. Required on every import row.

name

string

Yes

Non-empty contract name. Responses use contractName.

description

string

No

Free text.

type

string

Yes

fixedTerm, hourBank or recurring. Responses use contractType.

startDate

string

Yes

Calendar date YYYY-MM-DD.

endDate

string

Conditional

Required for fixedTerm and hourBank. Omit for recurring; derived from recurrence.

contractedHours

number

Conditional

Non-negative decimal hours. Required except for date-only fixedTerm (contractedHoursToggle: false).

contractedHoursToggle

boolean

No

Fixed-term only. Defaults to true; false gives date-only expiry without an hours target.

expiry

string

Conditional

Hour-bank only: hoursExhausted (default) or hoursOrEndDate.

consumedHours

number

No

Non-negative opening balance of used hours; default 0.

ratePerHour

number

No

Rate in the currency configured in app settings.

visibilityInPortal

boolean

No

Show contract metrics in the JSM portal; default false.

scope

object

Yes

{jql} or {field, fieldValue, projectKeys}.

recurrence

object

Conditional

Required for recurring: {recurrenceType: "monthly" | "yearly", rolloverHours: boolean, expiryMonths: number}.

A new contract returns 201 Created, a Contract body with CALCULATING, and Location: /v1/contracts/{id}. An existing externalId updates its contract and returns 200 OK. Reuse externalId on retries to avoid duplicates.

Bash
curl --request POST --url "$BASE/v1/contracts" \
  --header "Authorization: Bearer $ACCESS_TOKEN" --header "Accept: application/json" \
  --header "Content-Type: application/json" \
  --data '{
  "externalId": "efixia-4471",
  "name": "Gold Support",
  "description": "Annual hour bank for Globex",
  "type": "hourBank",
  "startDate": "2026-01-01",
  "endDate": "2026-12-31",
  "contractedHours": 120,
  "expiry": "hoursOrEndDate",
  "consumedHours": 37.5,
  "ratePerHour": 95,
  "visibilityInPortal": true,
  "scope": {
    "jql": "project = SUP AND \"Organizations\" = 10042"
  }
}'
JSON
{
  "id": "c_8f31a0d2",
  "externalId": "efixia-4471",
  "contractName": "Gold Support",
  "description": "Annual hour bank for Globex",
  "contractType": "hourBank",
  "status": "CALCULATING",
  "startDate": "2026-01-01",
  "endDate": "2026-12-31",
  "contractedHours": 120,
  "consumedHours": 37.5,
  "loggedHours": 0,
  "expiry": "hoursOrEndDate",
  "ratePerHour": 95,
  "visibilityInPortal": true,
  "scope": {
    "jql": "project = SUP AND \"Organizations\" = 10042"
  },
  "createdAt": "2026-09-25T10:04:11.000Z",
  "lastCalculatedAt": null
}

Poll GET /v1/contracts/c_8f31a0d2 until its status becomes ACTIVE or EXPIRED. FAILED means computation needs attention.

Errors: 400 VALIDATION (malformed JSON, required/invalid fields, endDate before startDate, missing recurrence, empty scope or invalid JQL); 403 LICENSE_INACTIVE; 403 EDITION_REQUIRED; 500 INTERNAL.

Import contracts

POST /v1/contracts/import

Required app scope: write:contract:custom. Body: {"contracts": [...]}, containing 1–200 contract objects with the create fields. externalId is required on every row. Validation runs synchronously; valid rows are queued for background processing.

202 response field

Type

Meaning

batchId

string

Poll GET /v1/imports/{batchId}.

accepted

array

Queued rows: {externalId, id, status: "CALCULATING"}.

rejected

array

Invalid rows: {externalId, errors: [...]}; not queued.

Bash
curl --request POST --url "$BASE/v1/contracts/import" \
  --header "Authorization: Bearer $ACCESS_TOKEN" --header "Accept: application/json" \
  --header "Content-Type: application/json" \
  --data '{
  "contracts": [
    {
      "externalId": "efixia-4471",
      "name": "Gold Support",
      "type": "hourBank",
      "startDate": "2026-01-01",
      "endDate": "2026-12-31",
      "contractedHours": 120,
      "expiry": "hoursOrEndDate",
      "consumedHours": 37.5,
      "scope": {
        "jql": "project = SUP AND \"Organizations\" = 10042"
      }
    },
    {
      "externalId": "efixia-4490",
      "name": "Q1 Migration",
      "type": "fixedTerm",
      "startDate": "2026-01-01",
      "endDate": "2026-03-31",
      "contractedHours": 40,
      "scope": {
        "jql": "project = SUP AND labels = migration"
      }
    },
    {
      "externalId": "efixia-9999",
      "name": "Bad Row",
      "type": "fixedTerm",
      "startDate": "2026-05-01",
      "endDate": "2026-01-01",
      "contractedHours": 10,
      "scope": {
        "jql": "project = SUP"
      }
    }
  ]
}'
JSON
{
  "batchId": "imp_9f3a12c7",
  "accepted": [
    {
      "externalId": "efixia-4471",
      "id": "c_8f31a0d2",
      "status": "CALCULATING"
    },
    {
      "externalId": "efixia-4490",
      "id": "c_2a7c55e1",
      "status": "CALCULATING"
    }
  ],
  "rejected": [
    {
      "externalId": "efixia-9999",
      "errors": [
        "endDate before startDate"
      ]
    }
  ]
}

Inspect rejected rows even when the request returns 202. Correct and resubmit rejected rows. Idempotency is per row by externalId. Split larger migrations into batches of at most 200 and submit them in sequence.

Errors: 400 VALIDATION for malformed JSON, missing/empty/oversized contracts array or a row missing externalId. Other per-row validation errors appear in rejected. Also 403 LICENSE_INACTIVE, 403 EDITION_REQUIRED, gateway 413 for an oversized body, and 500 INTERNAL.

Get import status

GET /v1/imports/{batchId}

Required app scope: read:contract:custom. batchId is the string returned by import.

200 response field

Type

Meaning

batchId

string

Import batch identifier.

total

integer

Contracts accepted for processing.

completed

integer

Contracts whose computation reached ACTIVE or EXPIRED.

failed

integer

Contracts that reached FAILED.

isFinished

boolean

True when completed + failed equals total. A finished batch can still contain failures.

Bash
curl --request GET --url "$BASE/v1/imports/imp_9f3a12c7" \
  --header "Authorization: Bearer $ACCESS_TOKEN" --header "Accept: application/json"
JSON
{
  "batchId": "imp_9f3a12c7",
  "total": 2,
  "completed": 1,
  "failed": 0,
  "isFinished": false
}

Progress is computed from the current contracts. Deleting a batch contract before its computation finishes can leave isFinished false indefinitely. For a stalled batch, reconcile the accepted IDs against List contracts and account for deleted IDs.

Errors: 400 VALIDATION for a missing batchId; 404 NOT_FOUND for an unknown batch or one aged out of history.

List contracts

GET /v1/contracts

Required app scope: read:contract:custom. Returns site contracts, newest first. All query parameters are optional and combine with AND.

Query parameter

Type / default

Meaning

status

List; no default

ACTIVE, EXPIRED, CALCULATING or FAILED.

type

List; no default

fixedTerm, hourBank or recurring.

externalId

string

Lookup by external ID; empty page when not found.

q

string

Contract name contains this text; minimum 2 characters.

pageSize

integer; default 50

1–100. Above 100 is capped at 100; missing/invalid values fall back to 50.

cursor

string

Opaque nextCursor from the previous response. Keep the same filters.

List filters can repeat (status=ACTIVE&status=EXPIRED) or use commas (status=ACTIVE,EXPIRED). Unknown status/type values match no contracts rather than returning a validation error.

Bash
curl --get --url "$BASE/v1/contracts" \
  --header "Authorization: Bearer $ACCESS_TOKEN" --header "Accept: application/json" \
  --data-urlencode "status=ACTIVE" \
  --data-urlencode "type=hourBank"
JSON
{
  "rows": [
    {
      "id": "c_8f31a0d2",
      "externalId": "efixia-4471",
      "contractName": "Gold Support",
      "description": "Annual hour bank for Globex",
      "contractType": "hourBank",
      "status": "ACTIVE",
      "startDate": "2026-01-01",
      "endDate": "2026-12-31",
      "contractedHours": 120,
      "consumedHours": 37.5,
      "loggedHours": 22,
      "remainingHours": 60.5,
      "expiry": "hoursOrEndDate",
      "ratePerHour": 95,
      "visibilityInPortal": true,
      "scope": {
        "jql": "project = SUP AND \"Organizations\" = 10042"
      },
      "createdAt": "2026-09-25T10:04:11.000Z",
      "lastCalculatedAt": "2026-09-25T10:06:02.000Z"
    }
  ],
  "nextCursor": null
}

Response 200 OK: rows contains Contract objects; nextCursor is a string or null. Continue requesting with cursor and unchanged filters until nextCursor is null. Do not parse or construct cursors.

Bash
curl --get --url "$BASE/v1/contracts" \
  --header "Authorization: Bearer $ACCESS_TOKEN" --header "Accept: application/json" \
  --data-urlencode "externalId=efixia-4471"

Errors: 400 VALIDATION for q shorter than 2 characters or an invalid cursor.

Get a contract

GET /v1/contracts/{id}

Required app scope: read:contract:custom. id is the app’s contract ID, returned by create, import or list. Response: 200 OK with a Contract. Error: 404 NOT_FOUND.

Bash
curl --request GET --url "$BASE/v1/contracts/c_8f31a0d2" \
  --header "Authorization: Bearer $ACCESS_TOKEN" --header "Accept: application/json"
JSON
{
  "id": "c_8f31a0d2",
  "externalId": "efixia-4471",
  "contractName": "Gold Support",
  "description": "Annual hour bank for Globex",
  "contractType": "hourBank",
  "status": "ACTIVE",
  "startDate": "2026-01-01",
  "endDate": "2026-12-31",
  "contractedHours": 120,
  "consumedHours": 37.5,
  "loggedHours": 22,
  "remainingHours": 60.5,
  "expiry": "hoursOrEndDate",
  "ratePerHour": 95,
  "visibilityInPortal": true,
  "scope": {
    "jql": "project = SUP AND \"Organizations\" = 10042"
  },
  "createdAt": "2026-09-25T10:04:11.000Z",
  "lastCalculatedAt": "2026-09-25T10:06:02.000Z"
}

Contract response fields

Field

Type

Meaning

id

string

App contract identifier.

externalId

string | null

Your contract identifier; null for UI-created contracts.

contractName

string

Contract name; request field is name.

description

string

Free text.

contractType

string

fixedTerm, hourBank or recurring; request field is type.

status

string

CALCULATING, ACTIVE, EXPIRED or FAILED.

startDate

string

YYYY-MM-DD.

endDate

string

YYYY-MM-DD; 2099-12-31 means no end date.

contractedHours

number

Hours covered; 0 for date-only fixedTerm.

consumedHours

number

Opening balance of used hours, added to loggedHours.

loggedHours

number

Matching Jira worklog hours; 0 while CALCULATING.

remainingHours

number

max(0, contractedHours − loggedHours − consumedHours). Omitted for date-only fixedTerm.

expiry

string

Hour-bank only: hoursExhausted or hoursOrEndDate.

ratePerHour

number

Rate per hour in configured currency.

visibilityInPortal

boolean

Whether contract metrics are shown in the JSM portal.

scope

object

Issue scope described above.

recurrence

object

Recurring only: recurrenceType, rolloverHours, expiryMonths.

createdAt

string

UTC creation timestamp.

lastCalculatedAt

string | null

UTC time loggedHours was last computed; null if never.

Update a contract

PUT /v1/contracts/{id}

Required app scope: write:contract:custom. Send any subset of create fields; omitted fields stay unchanged. Response: 200 OK with the updated Contract.

Changing dates or issue scope triggers background calculation and returns CALCULATING. Changing only name, description, visibilityInPortal or ratePerHour does not. Changing only contractedHours derives status from existing hours without fetching worklogs again.

Bash
curl --request PUT --url "$BASE/v1/contracts/c_8f31a0d2" \
  --header "Authorization: Bearer $ACCESS_TOKEN" --header "Accept: application/json" \
  --header "Content-Type: application/json" \
  --data '{
  "contractedHours": 160
}'
JSON
{
  "id": "c_8f31a0d2",
  "externalId": "efixia-4471",
  "contractName": "Gold Support",
  "contractType": "hourBank",
  "status": "ACTIVE",
  "contractedHours": 160,
  "consumedHours": 37.5,
  "loggedHours": 22,
  "remainingHours": 100.5,
  "lastCalculatedAt": "2026-09-25T10:06:02.000Z"
}

The example shows the relevant response fields. Errors: the create endpoint’s validation, licensing, edition and internal errors, plus 404 NOT_FOUND.

Delete a contract

DELETE /v1/contracts/{id}

Required app scope: delete:contract:custom. No request body. Removes the contract and its stored audit; Jira issues and worklogs are unchanged.

Bash
curl --request DELETE --url "$BASE/v1/contracts/c_8f31a0d2" \
  --header "Authorization: Bearer $ACCESS_TOKEN" --header "Accept: application/json"
JSON
{
  "id": "c_8f31a0d2",
  "deleted": true
}

Response: 200 OK. Errors: 400 VALIDATION for a missing ID; 404 NOT_FOUND when the contract does not exist, including a repeated delete.

Versioning and changelog

The version appears in the path (/v1). v1 changes are backward-compatible: new endpoints, optional parameters, response fields and error codes. Breaking changes use a new version path and are announced before retiring the previous version.

Date

Change

8 October 2026

v1: create, import, import status, list, get, update and delete contracts.

Last updated: