atlassian-cli is an independent, community open-source project, not affiliated with, endorsed by, or sponsored by Atlassian, and is not Atlassian's official CLI (acli). Product names are used only to identify compatibility.
What Are Jira Webhooks?
Jira webhooks are outbound HTTP callbacks: when something happens in your Jira project (an issue is created, transitioned, commented on, or deleted), Jira Cloud sends an HTTP POST with a JSON body to a URL you register. To react to one in a script you need three things: a webhook pointed at a URL you control, a small listener running at that URL, and a command to run when the payload arrives. This post wires all three together and uses atlassian-cli as the "do something" step, so a status change in Jira can automatically assign, transition, or update the affected issue.
The mental model is simple. Jira is the event source. Your listener is a translator that turns raw JSON into a decision. atlassian-cli is the actuator that carries the decision back into Jira (or into Confluence, Bitbucket, or JSM). Because the actuator is a plain command-line binary, the whole reaction fits in a few lines of Bash or Python with no SDK, no framework, and no build step.
How this differs from Atlassian's own tooling: atlassian-cli is an independent, community open-source project. It is not Atlassian's official CLI (acli) and is not affiliated with, endorsed by, or sponsored by Atlassian. Use the official acli for first-party vendor support; use atlassian-cli if you want a single free Rust binary spanning Jira, Confluence, Bitbucket, and JSM that you can drop into a webhook handler.
Anatomy of the Payload
Before you can react to anything you need to know what Jira sends. An issue webhook payload is a JSON object. The two fields you will read on almost every event are webhookEvent (what happened) and issue.key (which issue). Update events add a changelog that tells you exactly which fields moved and from what to what.
// jira:issue_updated payload (trimmed to the useful parts)
{
"timestamp": 1752105600000,
"webhookEvent": "jira:issue_updated",
"issue_event_type_name": "issue_generic",
"user": {
"accountId": "5f8a1b2c3d4e",
"displayName": "Dana Ops"
},
"issue": {
"key": "DEV-482",
"fields": {
"summary": "Payment retry fails on timeout",
"status": { "name": "Done" },
"priority": { "name": "High" },
"assignee": null
}
},
"changelog": {
"items": [
{ "field": "status", "fromString": "In Review", "toString": "Done" }
]
}
}
The common event names are worth memorizing because your listener will branch on them:
| webhookEvent | Fires when | Has changelog? |
|---|---|---|
jira:issue_created |
A new issue is created | No |
jira:issue_updated |
A field changes (status, assignee, priority, ...) | Yes |
jira:issue_deleted |
An issue is deleted | No |
comment_created |
A comment is added to an issue | No |
Because the payload is ordinary JSON, you can parse it with jq in a shell script or with the standard library in any language. There is nothing Jira-specific about reading it.
Inspect Registered Webhooks
You register the webhook itself in Jira's admin settings (System → WebHooks), where you set the target URL and the events it should fire on. Once it exists, atlassian-cli can list the registered webhooks so you can confirm the URL and event scope from the terminal instead of clicking through the admin UI:
# List webhooks registered on this Jira instance
atlassian-cli jira webhooks list
# Machine-readable output for auditing or diffing
atlassian-cli jira webhooks list --format json | jq '.[] | {name, url, events}'
This is handy in review: before you trust a listener in production, dump the webhook list to JSON and check that the URL, the events, and any JQL filter are exactly what you expect. If you also drive Jira automation rules, atlassian-cli jira automation list shows those alongside, and atlassian-cli jira audit list --from 2025-01-01 --limit 100 lets you cross-check that the changes your listener made actually landed. See the full command reference for every flag.
A Minimal Listener
You do not need a web framework to receive a webhook. Python's standard library ships an HTTP server that is more than enough for a single endpoint. The listener below reads the POST body, parses it, extracts the event and issue key, and delegates the actual decision to a handler function.
#!/usr/bin/env python3
# jira_hook.py -- a minimal Jira webhook listener (stdlib only)
import json, os, subprocess, hmac
from http.server import BaseHTTPRequestHandler, HTTPServer
SECRET = os.environ["HOOK_SECRET"] # shared token set on the webhook URL
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
# 1. Reject anything without the correct shared token
token = self.path.split("token=")[-1]
if not hmac.compare_digest(token, SECRET):
self.send_response(403); self.end_headers(); return
# 2. Read and parse the JSON body
length = int(self.headers.get("Content-Length", 0))
payload = json.loads(self.rfile.read(length))
# 3. Acknowledge fast, then act
self.send_response(200); self.end_headers()
handle_event(payload)
def handle_event(payload):
event = payload.get("webhookEvent")
key = payload.get("issue", {}).get("key")
if not key:
return
if event == "jira:issue_created":
on_created(key, payload)
elif event == "jira:issue_updated":
on_updated(key, payload)
if __name__ == "__main__":
HTTPServer(("0.0.0.0", 8000), Handler).serve_forever()
Two design choices matter here. First, the listener acknowledges the request before doing work by sending the 200 response, then calling the handler. Jira expects a prompt response and may retry if you are slow; for anything heavier than a couple of commands you would push the payload onto a queue and return immediately. Second, the token check uses hmac.compare_digest for a constant-time comparison, which we come back to in the security section.
Calling atlassian-cli in Response
Now the interesting part: the handler functions that turn an event into a Jira action. These shell out to atlassian-cli. Every command below is a real subcommand, so you can copy them straight into your handler.
Auto-triage new high-priority issues. When an issue is created at High or Highest priority, assign it to the triage lead so it never sits unowned:
def on_created(key, payload):
priority = (payload["issue"]["fields"]
.get("priority") or {}).get("name", "")
if priority in ("Highest", "High"):
subprocess.run([
"atlassian-cli", "jira", "issue", "assign", key,
"--assignee", "triage-lead@example.com",
], check=True)
Close issues once review is done. When a status transition to "Done" arrives, move the issue on to "Closed" automatically:
def on_updated(key, payload):
changes = payload.get("changelog", {}).get("items", [])
moved_to_done = any(
c["field"] == "status" and c["toString"] == "Done"
for c in changes
)
if moved_to_done:
subprocess.run([
"atlassian-cli", "jira", "issue", "transition", key,
"--transition", "Closed", "--profile", "prod",
], check=True)
Prefer pure shell? The same reaction works with jq reading the payload from stdin. This is the entire logic of a Bash listener body:
# react.sh -- reads a webhook payload on stdin and acts
payload="$(cat)"
event="$(echo "$payload" | jq -r '.webhookEvent')"
key="$(echo "$payload" | jq -r '.issue.key')"
if [ "$event" = "jira:issue_updated" ]; then
status="$(echo "$payload" | jq -r '.issue.fields.status.name')"
if [ "$status" = "Done" ]; then
atlassian-cli jira issue transition "$key" --transition "Closed"
fi
fi
Notice you are never limited to Jira. Because the same binary spans products, a Jira event can drive a cross-product reaction: a released issue could trigger atlassian-cli confluence page create for release notes, or a merge event could feed back into Jira. That is the whole appeal of an actuator that speaks every API. For a broader look at rule-based flows, see the Jira automation guide.
Guarding Against Loops
The single most common mistake with webhook reactions is the feedback loop. Your listener reacts to jira:issue_updated by writing back to the same issue with atlassian-cli. That write is itself a change, so Jira fires another jira:issue_updated, which your listener reacts to again. Left unguarded, one human edit can spiral into an endless chain of API calls.
Two guards, used together, stop this cold:
- Ignore your own account. The token you authenticate atlassian-cli with belongs to an account with its own
accountId. Every payload includesuser.accountIdfor whoever triggered the event. If that ID is your automation account, return early and do nothing. Your bot never reacts to itself. - Make actions idempotent. Check the current state before writing. If the transition to "Closed" only runs when the status actually changed to "Done" this event (which you read from the
changelog), re-processing a stale duplicate delivery changes nothing.
BOT_ACCOUNT = "5f8a1b2c3d4e" # the account atlassian-cli authenticates as
def handle_event(payload):
# Guard 1: never react to changes our own bot made
if payload.get("user", {}).get("accountId") == BOT_ACCOUNT:
return
...
Jira may also deliver the same event more than once, so idempotency is not optional even without loops. Reading the changelog to confirm the specific transition happened this event, rather than reacting to whatever the current status is, gives you both loop protection and duplicate protection in one check.
Securing the Endpoint
Your listener is a public URL that runs commands. Treat it accordingly. Admin-registered Jira Cloud webhooks do not HMAC-sign their requests the way some other systems do, so the pragmatic approach is a shared secret baked into the webhook URL as a query parameter, for example https://hooks.example.com/jira?token=LONG_RANDOM_STRING. Your listener rejects any request whose token does not match, and it compares with a constant-time function so the check cannot be timed.
Layer these practices on top:
- TLS only. Terminate HTTPS at your endpoint or reverse proxy so the token is never sent in the clear.
- Least-privilege token. Authenticate atlassian-cli with a dedicated account and API token that can only touch the projects it needs. Configure it once with a named profile and reference it via
--profile. - Validate before acting. Confirm the payload has the fields you expect before shelling out. A malformed body should be a no-op, not a crash.
- Network allow-listing. For defense in depth, restrict inbound traffic to Atlassian's published source ranges at your firewall or proxy.
The secret in the URL is not a substitute for TLS or network controls; it is the layer that answers "did this request really come from my Jira?" while the others limit blast radius if something slips through. Running the listener in CI or a container? The same command patterns apply, and the companion post on running the Jira CLI in GitHub Actions covers token handling in that setting.
Turn Jira events into actions
Install the single Rust binary and wire it into your webhook handler in minutes.
Try atlassian-cliFAQ
How do I trigger a script when a Jira issue changes?
Register a Jira webhook that points at a URL you control, then run a small HTTP listener at that URL. Jira Cloud POSTs a JSON payload to your endpoint on each matching event. Your listener parses the payload, pulls out the issue key and event type, and shells out to atlassian-cli to take action, for example atlassian-cli jira issue transition or atlassian-cli jira issue assign.
What does a Jira webhook payload look like?
A Jira issue webhook payload is a JSON object with a webhookEvent field (such as jira:issue_created or jira:issue_updated), a user object for whoever triggered it, and an issue object containing the key and fields. Update events also include a changelog object listing which fields changed, with fromString and toString values.
How do I stop a Jira webhook from causing an infinite loop?
A loop happens when your listener reacts to an update event by writing back to the same issue, which fires another update event. Guard against it two ways: ignore events triggered by your automation account by checking user.accountId in the payload, and make your action idempotent so re-processing the same state changes nothing. Both together keep the reaction from feeding itself.
Can I secure a Jira webhook without HMAC signing?
Admin-registered Jira Cloud webhooks do not HMAC-sign their requests, so the pragmatic guard is a shared secret embedded in the webhook URL as a query parameter. Your listener rejects any request whose token does not match, using a constant-time comparison. Terminate TLS at your endpoint and optionally restrict inbound traffic to Atlassian source ranges for defense in depth.