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.

Duplicate-safe Jira create-or-update by external id A correlated case maps to Jira issue fields and derives an external id from the case id. A JQL search for that external id decides the path: a hit updates the existing issue, a miss creates a new issue stamped with the external id under a local idempotency claim. A create conflict triggers a reconciliation search that adopts the existing issue rather than leaving a duplicate. Map case → fields external_id = case id JQL finds it? external_id search Update append delta Create stamp id · claim hit miss

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.