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.
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.
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"
}
}'
{
"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. |
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"
}
}
]
}'
{
"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. |
curl --request GET --url "$BASE/v1/imports/imp_9f3a12c7" \
--header "Authorization: Bearer $ACCESS_TOKEN" --header "Accept: application/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.
curl --get --url "$BASE/v1/contracts" \
--header "Authorization: Bearer $ACCESS_TOKEN" --header "Accept: application/json" \
--data-urlencode "status=ACTIVE" \
--data-urlencode "type=hourBank"
{
"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.
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.
curl --request GET --url "$BASE/v1/contracts/c_8f31a0d2" \
--header "Authorization: Bearer $ACCESS_TOKEN" --header "Accept: application/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.
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
}'
{
"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.
curl --request DELETE --url "$BASE/v1/contracts/c_8f31a0d2" \
--header "Authorization: Bearer $ACCESS_TOKEN" --header "Accept: application/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. |
Related documentation