A ticket that can't be investigated is just a notification with a number
Most detection-to-ticket integrations we've been asked to pick apart were built to prove one thing: when a detection fires, a ticket appears. That part works. It works for weeks.
Then someone has to actually investigate one. The enrichment is a single long sentence in the description, the hostname doesn't resolve, the indicator list stops wherever the API felt like stopping, and there's no way back to the console that raised the alert. The analyst starts from scratch, in the one tool that already had every answer.
A ticket is a handoff contract, not a notification. It moves an investigation across an organisational boundary — detection engineering to the SOC, the SOC to IT operations — and whoever picks it up has to be able to work from the ticket alone. Everything below is about designing that contract deliberately, on any stack.
Start by choosing the integration shape
| Shape | What crosses the boundary | When it fits | What it gives up |
|---|---|---|---|
| Create-only | XDR/SOAR creates one record and stops | Simple notification or low-risk enrichment | No reliable action result, no later state reconciliation |
| Create plus callback | Automation creates a record; ServiceNow or an approval process calls back | Human approval before containment | Requires authenticated callback and durable correlation |
| Bi-directional sync | Both systems update status, notes, assignments, and references | Two teams actively work the same case | More races, field ownership rules, and loop prevention |
| Ticket per incident | One source incident maps to one ServiceNow record | Independent investigations and straightforward dedupe | Campaign context may be fragmented |
| Ticket per campaign | Several alerts map to one ServiceNow record | Many alerts are clearly one campaign | Harder correlation; a late event may change the scope |
| Merge into existing | New evidence updates an open record | Re-fires, retries, or related alerts should stay together | Requires a strong merge key and an explicit window |
Create-only
The simplest request is a POST to the Incident table or to a scripted REST endpoint that wraps it:
POST https://<instance>.service-now.com/api/now/table/incident
Content-Type: application/json
Authorization: Bearer <token>
{
"short_description": "Suspicious administrative access on <host>",
"impact": "1",
"urgency": "1",
"description": "Source incident: <incident-id>\nUser: <user>\nHost: <host>",
"correlation_id": "<incident-id>"
}
This works for an operational notification when the source platform remains the system of investigation. It is insufficient when ServiceNow users must see whether a response action completed.
Create plus callback
The callback shape is useful when ServiceNow owns an approval. XDR creates a record with the proposed action and waits. An approval or rejection sends back a small, authenticated decision:
{
"source_incident_id": "<incident-id>",
"service_now_sys_id": "<sys_id>",
"decision": "approved",
"action": "isolate",
"change_number": "CHG00xxxxx",
"decided_by": "<approver>",
"decided_at": "<timestamp>"
}
The callback should not carry an XDR access token or become a general-purpose command endpoint. It should identify one pending decision and carry the smallest possible decision object. Validate the incident key, the expected state, the action name, and whether the callback has already been processed.
Bi-directional sync
| Field | Typical owner | Why |
|---|---|---|
| Source incident ID | XDR/SOAR | It originates in detection and should not change |
ServiceNow number and sys_id | ServiceNow | They are created by ServiceNow |
| Short description | XDR/SOAR at create; ServiceNow after assignment | Prevents update loops |
| Investigation evidence | XDR/SOAR append-only | Keeps the source observation intact |
| Assignment group | ServiceNow | Operational routing belongs to ITSM |
| Containment result | Action platform | It knows whether the action was accepted or verified |
| Approval state | ServiceNow or change process | It is a human governance decision |
| Overall case state | Explicit state machine | Avoid “last writer wins” errors |
Without ownership, a sync loop is almost guaranteed. XDR writes “Open: Contained,” ServiceNow’s business rule maps that to “In Progress,” XDR sees the new value and writes again, and the two systems keep reacting to each other. Store a source marker, compare meaningful changes, and make updates idempotent.
Define the field contract before the workflow
Define each field, its source, format, required status, and missing-value behavior.
| Field | Example | Source | If missing |
|---|---|---|---|
| Source incident ID | <incident-id> | XDR incident | Stop correlation; do not create an untraceable record |
ServiceNow sys_id | <32-character sys_id> | ServiceNow create response | Store the create response; retry lookup before creating again |
| Incident number | INC00xxxxx | ServiceNow create response | Record sys_id; number can be read later |
short_description | Suspicious administrative access on <host> | Workflow summary | Use a safe fallback, but keep the source ID visible |
description | Structured evidence | Workflow context | Create only if the instance accepts a minimal record and mark evidence incomplete |
correlation_id | <incident-id> | XDR incident | Do not rely on title or timestamp as a substitute |
impact | 1, 2, or 3 | Response policy | Apply a documented default, never an empty value |
urgency | 1, 2, or 3 | Response policy | Apply a documented default |
| Configuration item | <CI> | Asset enrichment | Use a verified CI or leave it empty; do not invent a CI name |
| Affected host | <hostname> | Endpoint enrichment | Show lookup status and source ID separately |
| User/account | <user> | Identity or device map | Show “not available” versus “lookup failed” |
| Indicators | One per line | Incident summary | Say “none supplied,” not “clean” |
| Action result | succeeded, failed, skipped, pending | Action API | Do not infer from the ticket HTTP response |
| Source link | https://<xdr>/incidents/<id> | XDR | Keep the source ID even if the link cannot be built |
ServiceNow’s standard Table API supports CRUD operations against existing tables. The exact fields available depend on the target table and customizations, so the standard Incident payload is a starting point, not a guarantee that every instance accepts every field (Table API reference).
Choose your ticket unit: incident, campaign, or existing case
Ticket per source incident
Use one record for each source incident when it represents a meaningful investigation boundary:
source incident A -> ServiceNow incident A
source incident B -> ServiceNow incident B
Ticket per campaign
Use a campaign ticket when several source incidents are clearly the same event and the receiving team wants one queue item. The merge key might be a campaign ID, normalized message cluster, or analyst-approved case key. Do not merge on a shared sender, hostname, or title alone; those are evidence, not identity.
Keep the contributing source IDs in a child table, a related list, or an append-only section of the description:
Campaign key: <campaign-id>
Source incidents:
- <incident-a>
- <incident-b>
- <incident-c>
Merge into an existing open incident
same correlation key
AND existing record is open or in progress
AND last-seen is within the configured window
=> update existing record
If the existing case is closed, do not silently reopen it unless the policy explicitly says to. A new detection after closure may represent a new incident, a recurrence, or late evidence. Those are different decisions.
The full conditional-creation decision space
Thresholds
create if severity >= high
AND confidence >= confirmed
AND affected_asset_count > 0
A threshold should not be inferred from a display label alone. If “high” is a string in one connector and 1 in another, normalize it before evaluation. Keep both the normalized decision and the raw input in the run output.
Dedupe windows
A dedupe window should be long enough to absorb retries and sensor re-fires, but not so long that a genuinely new event disappears into an old case.
Store:
- Dedupe key
- First-seen timestamp
- Last-seen timestamp
- Number of re-fires
- Existing ServiceNow record reference
- Current case state
Re-fire behavior
When the same source incident fires again, choose one of these behaviors:
| Re-fire policy | Result |
|---|---|
| Ignore | No ticket update; safe only when duplicate contains no new evidence |
| Append | Add a timestamped work note and new evidence |
| Update | Replace selected mutable fields and preserve history |
| Re-evaluate | Re-run the eligibility gate; useful when enrichment was initially incomplete |
| Create new | Use only when the source system explicitly issued a new incident identity |
We prefer append plus re-evaluate. It preserves what was known at the first run while allowing a later enrichment pass to correct a missing host or user.
Merge versus split
Merge when the same investigation owns the evidence. Split when the operational action, owner, or blast radius differs. Two endpoints in one campaign may be one ticket for the SOC but separate fulfillment tasks for desktop support. The correct design can be one parent incident with related tasks rather than one enormous description.
Clear-then-retrigger
Define the sequence:
- Record the original run and its decision.
- Change the configuration.
- Issue a new source run with a new run ID.
- Reuse the source incident ID only if the test is a continuation of the same case.
- Append a work note that explains the configuration change.
- Reconcile the action result with the original ticket.
Do not delete the first record to make the second run look clean. The first run is evidence.
Identifier lifecycle: IDs are not interchangeable
- Source incident ID
- Alert or detection ID
- Endpoint/device ID
- Email message ID
- ServiceNow
sys_id - ServiceNow incident number
- Change number
- Action or remediation ID
- Workflow run ID
Each has a different owner and lifetime. Keep a correlation record:
{
"source_incident_id": "<incident-id>",
"source_run_id": "<workflow-run-id>",
"service_now_sys_id": "<sys_id>",
"service_now_number": "INC00xxxxx",
"action_ids": ["<action-id>"],
"last_observed_at": "<timestamp>"
}
Re-issued IDs
An endpoint may be offboarded and re-enrolled, producing a new device ID for the same hostname. A mailbox provider may expose a provider-specific ID that changes after migration. A detection may be closed and recreated with a new incident ID even though the visible title is the same.
Never treat a hostname, subject, or display name as a permanent primary key. If an ID changes, preserve the old ID as a historical reference and create an explicit alias relationship:
Current device ID: <new-device-id>
Previous device ID: <old-device-id>
Reason: endpoint re-enrolled
Partial enrichment is a real outcome
- Confirmed: returned and validated
- Not found: lookup completed with no match
- Not available: upstream data did not include the field
- Failed: request or parser failed
- Skipped: policy prevented the lookup or action
- Pending: request accepted; final result not known
Affected assets
- <host-a>: confirmed; endpoint link available
- <host-b>: lookup failed; device ID <id> retained for follow-up
Users involved
- <user-a>: confirmed
- User mapping: not available for <host-b>
Overall enrichment: partial
Do not replace every missing value with “unknown” and then label the ticket “complete.” “Unknown” hides whether the field was absent, the lookup failed, or the workflow skipped it by design.
Journal fields have real semantics
ServiceNow’s comments and work_notes are journal fields, not ordinary string columns. On a standard Incident form, Additional comments are intended for requester/customer-visible communication, while Work notes are internal activity updates for support teams. The exact visibility can be changed by instance configuration, ACLs, portals, and business rules, so confirm the local behavior before putting sensitive evidence in either field.
description: the durable initial evidence snapshotcomments: an external-facing update only when the receiving process expects itwork_notes: internal action results, lookup failures, approvals, and audit context- custom journal field: only if the instance has a defined contract
Appending a work note through the Table API is different from replacing the description. A PATCH that supplies a new journal value adds an activity entry; it does not give you a normal “current value” field to compare like short_description. Retrieval of journal history may require the instance’s supported journal APIs or query parameters. Test this behavior against the actual instance rather than assuming a GET returns the complete activity stream.
Automation update
Source incident: <incident-id>
Action: endpoint lookup
Target: <host>
Result: not found
Next step: analyst review required
Never put bearer tokens, API keys, client secrets, or raw authorization headers in a journal field. Journal visibility is a security boundary, not a secret store.
Update failures, retries, and races
The dangerous failure is a timeout after ServiceNow accepted the request. If the workflow retries with another POST, it may create a duplicate incident.
Use a create/update sequence that tolerates uncertainty:
- Generate a stable correlation key.
- Search for an existing record using the key and any local mapping.
- If found, PATCH the record.
- If not found, POST a new record.
- Persist the returned
sys_idand incident number immediately. - If the response is lost, search by correlation key before retrying POST.
- If both search and create are uncertain, stop and alert rather than creating blindly.
PATCH https://<instance>.service-now.com/api/now/table/incident/<sys_id>
Content-Type: application/json
{
"work_notes": "Containment result for <incident-id>: action accepted; verification pending.",
"u_source_incident_id": "<incident-id>",
"u_automation_state": "pending_verification"
}
The race between action and ticket
Suppose endpoint isolation succeeds but ticket creation fails, or the work-note update fails. Track per-step results:
Source incident: known
Isolation request: accepted
Isolation verification: succeeded
ServiceNow create: failed
XDR work note: succeeded
Overall handoff: incomplete; ticket reconciliation required
Keep the action result independent from the ticket result. A ticket failure must not cause an already-completed containment action to be retried blindly.
Preventing update loops
Every update should carry a source and event identity. Before reacting to a ServiceNow update, ask:
- Was this field changed by the automation integration?
- Is this the same event already processed?
- Has the source incident moved to a state where another update is appropriate?
- Will writing back create the same trigger again?
source incident ID + action type + action ID + result state
Store processed keys and make the callback idempotent.
Worked scenario: one host, one user, one ticket
Input:
{
"source_incident_id": "incident-<id>",
"title": "Suspicious administrative access",
"severity": "high",
"assets": [
{
"hostname": "HOST-A",
"device_id": "<device-id>",
"link": "https://<endpoint-console>/devices/<device-id>"
}
],
"users": ["<user>"],
"indicators": ["<indicator>"],
"action": {
"type": "report",
"result": "completed"
}
}
Payload:
{
"short_description": "Suspicious administrative access on HOST-A",
"impact": "1",
"urgency": "1",
"correlation_id": "incident-<id>",
"description": "Source incident: incident-<id>\n\nAffected assets\n- HOST-A: https://<endpoint-console>/devices/<device-id>\n\nUsers involved\n- <user>\n\nIndicators\n- <indicator>\n\nAutomation result\n- Reporting completed"
}
The receiving analyst can identify the source case, the device, the user, the indicator, and the action state without opening three other tools.
Worked scenario: partial enrichment and a safe no-ticket result
Input:
{
"source_incident_id": "incident-<id>",
"assets": [
{"hostname": "HOST-A", "device_id": "<id-a>"},
{"hostname": "HOST-B", "device_id": "<id-b>"}
],
"users": [],
"lookup": {
"HOST-A": "confirmed",
"HOST-B": "failed"
}
}
If policy requires at least one mapped user before creating a high-priority incident, do not create a ticket merely because two asset IDs exist. Write an XDR or integration note:
Ticket decision: not created
Reason: no users were confirmed
Enrichment: HOST-A confirmed; HOST-B lookup failed
Source incident: incident-<id>
Next step: analyst review
That is not lost work. It is a deliberate conditional outcome.
Worked scenario: re-fire after a timeout
The first POST times out. You do not know whether ServiceNow created the record.
Bad retry:
POST /api/now/table/incident -> timeout
POST /api/now/table/incident -> create another record
Safer retry:
POST -> timeout
GET/filter by u_source_incident_id=incident-<id>
found -> PATCH existing record
not found -> POST once, then persist sys_id
query failed -> stop and send reconciliation alert
The integration should make the uncertain state visible to an operator. “Retrying” is not a result.
Test the handoff, not only the request
A useful test matrix includes the happy path and the cases that expose the real design:
| Test | Expected result |
|---|---|
| One valid incident | One ServiceNow record with source ID and evidence |
| Same incident runs twice | One record; second run appends or updates |
| Two incidents share a title | Two records unless the correlation policy says otherwise |
| Multiple hosts and users | All entities listed individually and linked where possible |
| Empty user map | Conditional ticket gate behaves as documented |
| One enrichment lookup fails | Partial result is recorded; other hosts continue |
| POST times out | Search before retry; no blind duplicate |
| Existing record is closed | Reopen, append, or create-new follows explicit policy |
| Work note update fails | Ticket/action result stays separate; reconciliation required |
| ServiceNow rejects a field | Error identifies field; safe fallback does not erase evidence |
| Callback repeats | Same event is ignored after the first accepted decision |
| ServiceNow changes a field | No infinite bi-directional update loop |
Verify three places after each test: the workflow run, the ServiceNow record, and the source incident. A green workflow run is not enough.
Troubleshooting checklist
The ticket exists but has no evidence
Check the payload before the HTTP activity. If the description was built from an empty variable or a nested object was stringified incorrectly, ServiceNow is only exposing the upstream problem. Confirm that indicators is the actual list under the response’s data property when the connector wraps results that way.
The same alert creates multiple tickets
Check the correlation key, the lookup-before-create path, and the timeout branch. A title-based dedupe is not reliable. Confirm that the sys_id returned by a successful create is persisted before the next activity runs.
A PATCH returns success but no visible work note
Confirm that the request used the correct journal field (work_notes or comments), that the integration user can write it, and that the instance’s ACLs and business rules allow the update. Journal fields do not behave like ordinary scalar fields.
The ticket says “completed” while containment failed
Separate the action result from the ServiceNow result. A successful ticket POST does not prove that an endpoint, mailbox, or firewall accepted the response action.
The ServiceNow reference is missing in XDR
Treat the source incident update as a separate step. If ticket creation succeeded but the source update failed, persist the ServiceNow number and sys_id in a reconciliation queue or operator report. Do not rerun the containment action merely to repair a reference.
Bi-directional updates loop forever
Add source markers and event IDs. Establish field ownership. Ignore updates that originated from the same integration event, and make callbacks idempotent.