The Jira Worklog REST API: a practical guide with examples

The Jira worklog REST API end to end: auth, add/read/edit/delete, the bulk worklog/updated and worklog/list endpoints, plus where Cloud and Server differ.

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.

CloudServer / Data Center
Base path/rest/api/3/rest/api/2
Hosthttps://your-domain.atlassian.netyour own https://jira.example.com
AuthAPI token over HTTP BasicPersonal Access Token over Bearer
Credential sourceid.atlassian.com, Security, API tokensprofile, 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

ConcernCloudServer / Data Center
Base path/rest/api/3/rest/api/2
AuthAPI token, HTTP BasicPAT, Bearer (or Basic on older builds)
Comment fieldAtlassian Document Format objectplain string
comment in responsesADFplain string
started format...000+0000, identical on bothsame
CRUD paths and adjustEstimateidenticalidentical
worklog/updated richnessfullthinner on older versions
Rate limitsdynamic cost budget, 429 + Retry-Afternone 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.

Frequently asked questions

What is the REST API endpoint to log work in Jira?
POST /rest/api/3/issue/{issueIdOrKey}/worklog on Jira Cloud, or /rest/api/2/issue/{issueIdOrKey}/worklog on Server and Data Center. The body needs timeSpentSeconds (an integer) and started (an ISO 8601 timestamp). The comment field is Atlassian Document Format on Cloud and a plain string on Server. The adjustEstimate option is a query parameter, not a body field.
Why does my Jira worklog POST return a 400?
Three usual causes. The comment format: Cloud needs an Atlassian Document Format object and rejects a plain string, while Server wants a plain string. The started timestamp: Jira accepts it only as 2026-05-29T14:30:00.000+0000, with milliseconds and a numeric offset, so a trailing Z or a +00:00 offset fails. And time tracking being disabled on the project, which surfaces as a vague 400. One catch: a malformed started can come back as a 500 rather than a 400, especially on Server and Data Center, where Jira's date parser throws instead of returning a clean validation error.
How do I get every Jira worklog changed since a date?
Call GET /rest/api/3/worklog/updated?since={epochMillis}. It returns worklog IDs in pages of up to 1000, with a nextPage cursor. Fetch the full records by POSTing those IDs (up to 1000 per request) to /rest/api/3/worklog/list. Pair it with /worklog/deleted to catch removals.
What is the difference between the Cloud and Server worklog API?
Cloud is /rest/api/3, comments are Atlassian Document Format, and auth is an API token over HTTP Basic. Server and Data Center are /rest/api/2, comments are plain strings, and auth is a Personal Access Token over Bearer. The endpoint paths and query parameters otherwise line up.
Does the Jira worklog API have a rate limit?
Jira Cloud enforces a dynamic, cost-based budget rather than a fixed number, and returns 429 Too Many Requests with a Retry-After header in seconds. Honor that header. Server and Data Center publish no fixed limit, but admins can throttle or put a reverse proxy in front.