Turning a correlated alert into a Jira issue is trivial to do wrong — one careless integration opens a fresh issue on every redelivery and buries the queue in duplicates. This guide implements a duplicate-safe Jira create-or-update, as one precise technique within automated ticket creation and the broader SOAR playbook & response automation discipline.
Root-Cause Context
The Jira REST API’s create endpoint is not idempotent: POST /rest/api/3/issue opens a new issue every time it is called, so a case that is redelivered from the queue, retried after a timeout, or processed by two workers yields two, three, or eight issues for one incident. The fix is to make your integration idempotent even though the API is not, by storing a stable external identifier on the issue and searching for it before creating.
Jira supports this through a custom field (commonly a labels entry or a dedicated text field) holding the case id. Before creating, the integration runs a JQL search for that external id; a hit means update the existing issue, a miss means create one and stamp the external id. The subtlety is the race: two workers can both search, both miss, and both create. Guarding the create with a local idempotency claim — the same pattern used for any idempotent playbook step — closes that window, and a reconciliation search on conflict cleans up any duplicate that slips through.
Prerequisites
The integration targets Python 3.11+ with an async HTTP client for the Jira REST API. Jira credentials come from a secrets manager, never hard-coded. It plugs into the parent automated ticket creation gate.
pip install "aiohttp>=3.9,<4.0" "pydantic>=2.6,<3.0"
Production-Ready Implementation
The client maps the case to Jira fields, searches by external id via JQL, and updates or creates accordingly. The HTTP client is injected so the logic is testable without a live Jira.
from __future__ import annotations
import logging
from typing import Awaitable, Callable, Optional, Protocol
from pydantic import BaseModel, Field
logger = logging.getLogger("soc.soar.jira")
class CorrelatedCase(BaseModel):
case_id: str = Field(min_length=1)
title: str = Field(min_length=1)
severity: int = Field(ge=0, le=100)
host_id: str = Field(min_length=1)
technique_id: str | None = None
def to_issue_fields(case: CorrelatedCase, project_key: str) -> dict:
priority = ("Highest" if case.severity >= 90 else
"High" if case.severity >= 70 else
"Medium" if case.severity >= 40 else "Low")
return {
"fields": {
"project": {"key": project_key},
"issuetype": {"name": "Security Incident"},
"summary": case.title,
"priority": {"name": priority},
"labels": ["soc-auto", f"case-{case.case_id}",
case.technique_id or "no-attack"],
"description": (f"Automated from correlated case {case.case_id}. "
f"Host {case.host_id}. ATT&CK {case.technique_id or 'n/a'}."),
}
}
class JiraApi(Protocol):
async def search_by_label(self, label: str) -> Optional[str]: ... # returns key
async def create(self, payload: dict) -> str: ...
async def update(self, key: str, payload: dict) -> None: ...
async def sync_issue(
case: CorrelatedCase, api: JiraApi, project_key: str,
claim: Callable[[str], Awaitable[bool]],
) -> str:
payload = to_issue_fields(case, project_key)
label = f"case-{case.case_id}"
existing = await api.search_by_label(label) # JQL: labels = "case-..."
if existing is not None:
await api.update(existing, payload)
logger.info("jira updated case=%s key=%s", case.case_id, existing)
return existing
if not await claim(f"jira:{case.case_id}"): # local race guard
found = await api.search_by_label(label) # reconcile
if found:
return found
key = await api.create(payload) # ERR_TICKET_004 on failure
logger.info("jira created case=%s key=%s", case.case_id, key)
return key
if __name__ == "__main__":
import asyncio
class FakeJira:
def __init__(self) -> None:
self.created: list[dict] = []
self.store: dict[str, str] = {}
async def search_by_label(self, label: str):
return self.store.get(label)
async def create(self, payload: dict) -> str:
key = f"SEC-{len(self.created) + 1}"
self.created.append(payload)
self.store[payload["fields"]["labels"][1]] = key
return key
async def update(self, key: str, payload: dict) -> None:
pass
async def claim(_k: str) -> bool:
return True
async def main() -> None:
api = FakeJira()
case = CorrelatedCase(case_id="C-9", title="Ransomware on FIN-DB-1",
severity=95, host_id="FIN-DB-1", technique_id="T1486")
k1 = await sync_issue(case, api, "SEC", claim)
k2 = await sync_issue(case, api, "SEC", claim) # redelivery
assert k1 == k2 and len(api.created) == 1
asyncio.run(main())
The external id lives in a case-<id> label so a single JQL search (labels = "case-C-9") finds any prior issue; the local claim closes the two-workers-both-miss race, and a reconciliation search on a lost claim adopts the winner’s issue instead of creating a duplicate. Severity maps to Jira’s priority scale explicitly so a scoring-scale change is a one-line, testable edit.
Error-Code Reference
| Code | Meaning | Action |
|---|---|---|
ERR_TICKET_004 |
Jira API returned a transient error | Requeue with backoff; the search-first flow makes retry safe |
ERR_TICKET_011 |
Idempotency claim store unreachable | Pause; do not create issues you cannot dedupe |
ERR_TICKET_021 |
Field payload rejected by Jira (invalid field/value) | DLQ; fix the field mapping for the project schema |
ERR_TICKET_031 |
Create succeeded but label stamp failed | Reconcile by JQL; adopt the existing issue |
ERR_TICKET_042 |
Two issues found for one external id | Merge onto the older issue; record the merge |
Operational Notes
- Search before create, always. A JQL search on the external-id label is the primary duplicate guard; the create path is only reached on a genuine miss.
- Stamp a stable external id. Use the case id in a dedicated label or field so one incident maps to one issue forever, regardless of redelivery.
- Debounce updates during storms. A rapidly climbing case should produce one periodic issue update, not one per hit, to respect Jira’s rate limits.
- Keep credentials in a secrets manager. Never embed Jira tokens in the playbook; use short-lived credentials injected at runtime.
Verification Checklist
FAQ
How do I make Jira issue creation idempotent when the API is not?
Store a stable external identifier — the case id — in a Jira label or custom field, and search for it before every create. A hit updates the existing issue; a miss creates one and stamps the id. Because the API’s create endpoint always opens a new issue, this search-first pattern is what makes your integration idempotent, so redeliveries and retries converge on one issue rather than a pile of duplicates.
How do I stop two workers from both creating the issue?
Guard the create with a local idempotency claim so exactly one worker proceeds to create while the other, having lost the claim, re-runs the JQL search and adopts the winner’s issue. Even if the claim store is briefly unavailable and a duplicate slips through, a reconciliation search that merges onto the older issue cleans it up. The search-first flow plus the claim closes the race window that a bare create-if-not-found leaves open.