To log work in Jira from code you POST to one endpoint: POST /rest/api/3/issue/{issueIdOrKey}/worklog on Cloud, or /rest/api/2/issue/{issueIdOrKey}/worklog on Server and Data Center. The body needs two fields that trip up nearly everyone the first time. timeSpentSeconds is an integer (use timeSpent instead if you want the 1h 30m string form). started is an ISO 8601 timestamp that Jira accepts in exactly one shape: 2026-05-29T14:30:00.000+0000, with milliseconds and a numeric offset, no trailing Z. Get those two right and the rest of the worklog API is small and predictable.
I co-founded Planim Time, a desktop Jira tracker whose entire job is pushing and pulling worklogs through this API, so I have watched it return a 400 for every reason it has one. This post is the full map: authentication, the four CRUD endpoints, the bulk endpoints almost nobody documents, rate limits, and where Cloud and Server quietly disagree. If you just want to log time by hand or with JQL, the three native ways to log work cover that. This is the version for when no human is pressing Save.
The base URL and authentication
Where you send requests, and how you authenticate, are the first thing that differs between Cloud and Server.
| Cloud | Server / Data Center | |
|---|---|---|
| Base path | /rest/api/3 | /rest/api/2 |
| Host | https://your-domain.atlassian.net | your own https://jira.example.com |
| Auth | API token over HTTP Basic | Personal Access Token over Bearer |
| Credential source | id.atlassian.com, Security, API tokens | profile, Personal Access Tokens (Jira 8.14+) |
On Cloud you authenticate with HTTP Basic, where the username is your account email and the password is the API token (not your login password, which Atlassian disabled for the API years ago). With curl that is -u "[email protected]:$JIRA_TOKEN".
On Server and Data Center the modern path is a Personal Access Token sent as a bearer header: -H "Authorization: Bearer $JIRA_PAT". Older Server installs still accept Basic auth with a real username and password, but PATs are the right choice if your version has them.
One note that saves an afternoon: the worklog author is always the authenticated account. There is no author field you can set on create to log time on someone else’s behalf. If you need that, you need each person’s own token, or an app with impersonation scopes, not a single service account.
Adding a worklog (POST)
The full endpoint, with the one query parameter worth knowing on day one:
POST /rest/api/3/issue/{issueIdOrKey}/worklog?adjustEstimate=leave
adjustEstimate is how the API expresses the remaining-estimate dropdown from the worklog dialog. It takes auto (the default, subtracts what you logged), leave (touch nothing), new (requires a newEstimate parameter), or manual (requires reduceBy). Omit it and you get auto, which silently changes the issue’s Remaining Estimate on every call. On a scripted integration that is rarely what you want, so set it deliberately.
The Cloud body, with the comment in Atlassian Document Format:
curl -X POST \
-u "[email protected]:$JIRA_TOKEN" \
-H "Content-Type: application/json" \
"https://your-domain.atlassian.net/rest/api/3/issue/TT-42/worklog?adjustEstimate=leave" \
-d '{
"timeSpentSeconds": 3600,
"started": "2026-05-29T14:30:00.000+0000",
"comment": {
"type": "doc", "version": 1,
"content": [{"type": "paragraph",
"content": [{"type": "text", "text": "Reviewed the auth PR"}]}]
}
}'
That ADF comment is the part everyone hates. On Cloud a plain "comment": "Reviewed the auth PR" is rejected, because the rich-text fields moved to a structured document model. The minimal valid shape is the doc / paragraph / text nesting above. You can omit comment entirely if you have nothing to say.
Server and Data Center never made that move, so the same request there is shorter, with a plain-string comment against /rest/api/2:
curl -X POST \
-H "Authorization: Bearer $JIRA_PAT" \
-H "Content-Type: application/json" \
"https://jira.example.com/rest/api/2/issue/TT-42/worklog?adjustEstimate=leave" \
-d '{
"timeSpentSeconds": 3600,
"started": "2026-05-29T14:30:00.000+0000",
"comment": "Reviewed the auth PR"
}'
The same call from Python, where most real integrations live:
import requests
resp = requests.post(
"https://your-domain.atlassian.net/rest/api/3/issue/TT-42/worklog",
params={"adjustEstimate": "leave"},
auth=("[email protected]", JIRA_TOKEN),
json={
"timeSpentSeconds": 3600,
"started": "2026-05-29T14:30:00.000+0000",
"comment": {
"type": "doc", "version": 1,
"content": [{"type": "paragraph",
"content": [{"type": "text", "text": "Reviewed the auth PR"}]}],
},
},
)
resp.raise_for_status()
print(resp.json()["id"]) # the worklog id you will need for edits
If the POST fails, work down this list before reaching for the docs: the started format (a malformed value can come back as a 500 rather than a clean 400, especially on Server, where Jira’s date parser throws instead of validating), an ADF/plain-string mismatch for your deployment, and time tracking being switched off for the project, which the API reports as a generic 400 with no useful message and is the single most confusing failure here.
Reading worklogs (GET)
Two read shapes cover almost everything: all worklogs on an issue, and one worklog by id.
curl -u "[email protected]:$JIRA_TOKEN" \
"https://your-domain.atlassian.net/rest/api/3/issue/TT-42/worklog?startAt=0&maxResults=100"
The response wraps the entries in a paged envelope: startAt, maxResults, total, and a worklogs array. Each entry carries id, author, timeSpentSeconds, started, the comment, and a server-set updated timestamp. The pagination matters on long-running issues, because Jira will not hand you thousands of worklogs in one response. Walk it until you have seen total:
def issue_worklogs(key):
start, out = 0, []
while True:
page = requests.get(
f"{BASE}/issue/{key}/worklog",
params={"startAt": start, "maxResults": 100},
auth=AUTH,
).json()
out += page["worklogs"]
start += page["maxResults"]
if start >= page["total"]:
return out
For a single entry, append its id: GET /issue/TT-42/worklog/100023. There are also startedAfter and startedBefore query parameters (epoch milliseconds) if you only want a date window, which is handy for backfilling a specific period without pulling an issue’s entire history.
Updating and deleting (PUT and DELETE)
Edits and deletes share the same path as the read, with the worklog id on the end, and they accept the same adjustEstimate parameter as the create. That symmetry is the nice part of this API.
# Bump a logged hour to 90 minutes, leave the remaining estimate alone
curl -X PUT \
-u "[email protected]:$JIRA_TOKEN" \
-H "Content-Type: application/json" \
"https://your-domain.atlassian.net/rest/api/3/issue/TT-42/worklog/100023?adjustEstimate=leave" \
-d '{"timeSpentSeconds": 5400}'
# Delete it and hand the time back to Remaining
curl -X DELETE \
-u "[email protected]:$JIRA_TOKEN" \
"https://your-domain.atlassian.net/rest/api/3/issue/TT-42/worklog/100023?adjustEstimate=auto"
Two permissions gate this. Editing or deleting your own entries needs the Edit own worklogs / Delete own worklogs project permission; touching someone else’s needs the all variant. A locked-down project can let a token add worklogs but refuse to let it correct them, and you find out only at PUT time.
The bulk endpoints almost nobody documents
Everything above is per-issue. The moment you want to mirror a whole instance, sync to a local store, or feed a reporting pipeline, per-issue polling falls apart. You would have to know every issue in advance and GET each one. Jira has three instance-level endpoints for exactly this, and they are the least-documented corner of the worklog API.
GET /rest/api/3/worklog/updated?since={epochMillis}
GET /rest/api/3/worklog/deleted?since={epochMillis}
POST /rest/api/3/worklog/list
worklog/updated answers “which worklogs changed since timestamp T”, across the whole instance, no issue keys required. It does not return the worklogs themselves, only their IDs, paged up to 1000 at a time with a cursor:
{
"values": [{"worklogId": 100023, "updatedTime": 1748391000000}],
"since": 1748390400000,
"until": 1748391000000,
"lastPage": true,
"nextPage": "https://your-domain.atlassian.net/rest/api/3/worklog/updated?since=1748391000000"
}
You walk nextPage until lastPage is true, collecting IDs, then hydrate them in batches through worklog/list, which takes up to 1000 IDs per POST:
def changed_since(since_ms):
ids, url = [], f"{BASE}/worklog/updated?since={since_ms}"
page = {}
while url:
page = get(url).json() # get() handles 429, below
ids += [v["worklogId"] for v in page["values"]]
url = None if page["lastPage"] else page["nextPage"]
return ids, page["until"] # keep until as the next cursor
def hydrate(ids):
out = []
for i in range(0, len(ids), 1000): # worklog/list caps at 1000 ids
resp = requests.post(f"{BASE}/worklog/list",
json={"ids": ids[i:i + 1000]}, auth=AUTH)
out += resp.json()
return out
worklog/deleted has the identical shape and exists because a sync cursor cannot see deletions any other way: a worklog that vanished from an issue produces no “update”, so without this tombstone feed your local copy keeps a row Jira no longer has. Poll both on the same cursor.
This is the machinery underneath two-way sync, and it is awkward enough that most trackers skip it and only push. We chose to poll these endpoints rather than rely on Cloud webhooks, which are built around Connect and Forge apps and are fragile for a desktop binary using a user’s personal token. The conflict-resolution rules we built on top, and where they still slip, are a separate post on two-way worklog sync; this section is just the endpoints it stands on.
One firsthand warning for Data Center: on older Server versions worklog/updated returns thinner data than Cloud, not always enough to drive a clean delta. We fall back to a slower full-window pull there. If you target self-hosted Jira, test this endpoint against the actual version before you build a cursor loop on top of it.
Rate limits and the errors you will actually hit
Cloud does not publish a fixed requests-per-minute number. It runs a dynamic, cost-based budget, and when you exceed it you get 429 Too Many Requests with a Retry-After header in seconds. The only correct behaviour is to read that header and wait. In our sync passes the budget is not generous, so a bulk hydrate of a few thousand IDs will hit 429 on a busy workspace and you must back off rather than hammer:
import time
def get(url):
while True:
r = requests.get(url, auth=AUTH)
if r.status_code == 429:
time.sleep(int(r.headers.get("Retry-After", 5)))
continue
r.raise_for_status()
return r
The errors worth recognising on sight:
- 400 with no useful body. Usually an ADF/plain-string comment mismatch or time tracking disabled on the project.
- 500 on create. Most often a malformed
started: on Server and Data Center Jira’s date parser throws rather than returning a clean 400, a long-standing quirk. Check the timestamp format first. - 403. A permission issue: missing Work on issues to create, or the Edit/Delete worklogs permission to modify.
- 404 on an issue that exists. The token’s account cannot browse that project, so Jira hides the issue rather than admitting it is there.
- 429. Back off using
Retry-After, as above.
Cloud versus Server: the differences in one place
| Concern | Cloud | Server / Data Center |
|---|---|---|
| Base path | /rest/api/3 | /rest/api/2 |
| Auth | API token, HTTP Basic | PAT, Bearer (or Basic on older builds) |
| Comment field | Atlassian Document Format object | plain string |
comment in responses | ADF | plain string |
started format | ...000+0000, identical on both | same |
CRUD paths and adjustEstimate | identical | identical |
worklog/updated richness | full | thinner on older versions |
| Rate limits | dynamic cost budget, 429 + Retry-After | none fixed; admin-configurable |
The shape of the API is the same on both. What changes is the version number in the path, the comment encoding, and how you authenticate. Write your client to swap those three and one codebase covers both.
Where the API stops being worth it
The API is the right tool when there is no human in the loop: a CI job logging build time, a nightly billing export, a tracker mirroring worklogs into its own store. It is the wrong tool for logging your own afternoon, where the worklog dialog is faster than any script, and a poor fit for “how many hours did the team log this sprint”, which the built-in time tracking reports answer without code.
And if you find yourself writing a polling loop over worklog/updated to keep a local copy honest, that is a tracker you are building. We built Planim Time so you would not have to. Either way, the endpoints above are the whole surface, and now you know which 400 means what.