NEWS

Cisco MINT Partner! Learn more →

Automation Services
2026-09-15
6 min read

When the Docs Pointed to the Wrong Endpoint

The hard part of ingesting custom OCSF events into Cisco XDR is not the JSON. It is finding the production intake path, creating the right source identity, and refusing fields that appear in processed events.

Cisco XDR
OCSF
Custom Event Source
SOAR
Detection Engineering
Security Automation

When the Docs Pointed to the Wrong Endpoint

OCSF custom event intake: validate the bundle, send it to Findings Intake, and handle acceptance or retry.

The first push did not fail because the OCSF object was complicated. It failed because we trusted the wrong endpoint and then trusted a misleading authorization error. We kept the raw responses, narrowed the contract, and built the source around what the intake service actually accepted. The examples below are generalized from that work.

The first push failed before OCSF had anything to say about it. We used the host shown by the interactive API documentation, sent a normal Bearer token, and got nowhere. That host is a documentation proxy behind AWS SigV4. It is not the endpoint a script can call with the OAuth token used by the Findings Intake API.

The production North America endpoint is:

https://findings.us.security.cisco.com/api/v1/detection_findings

That distinction is not obvious when you start in the docs. The proxy is useful for reading the OpenAPI description, but it is the wrong destination for a direct Bearer-authenticated push. Use the region-specific production Findings Intake host for the POST; adjust the regional hostname when Cisco documents a different region for your tenant.

The second failure was worse because it looked like an ordinary permissions problem. We requested an OAuth token with a specific scope and the intake call returned:

403 explicit deny in an identity-based policy

Removing the scope parameter made the same flow work. That is an observed behavior of this integration path, not a reason to go hunting through IAM policies for an hour. The practical rule is simple: request the OAuth client token without a scope parameter, then send that token to the production findings endpoint.

This post covers the direct OCSF path. It is for a security engineer who has a useful detection source that Cisco XDR does not natively ingest and wants the result to appear as a first-class Detection Finding, not as an arbitrary JSON blob in a ticket.

OCSF is the contract, not decoration

OCSF gives XDR a common vocabulary for security events. A Detection Finding says what detected the activity, when it happened, how severe it is, and which entities or activities support it. A Network Activity event can carry the connection that makes a network finding useful.

Cisco XDR uses Detection Findings as inputs to correlation. Cisco’s custom security event documentation distinguishes those findings from incidents and from raw activities: the finding is the security event XDR can ingest and correlate; the incident is the grouped result. That is why formatting matters. XDR needs the event class, identifiers, timestamps, observables, and relationships in predictable locations.

You do not need to turn an IDS record into a standards lecture. You do need to map its source identity into a stable finding UID, its network facts into OCSF fields, and its rule or signature into evidence that an analyst can follow.

A custom source gives the event a home

A custom event source is the XDR-side identity for data that comes from a source Cisco does not manage as a native integration. It gives the incoming events a source name, lets XDR associate them with a tenant integration, and provides the module instance identifier required by the intake API.

The important identifier is the module instance, not the tenant UID. If your inventory records the tenant separately, keep it as <your-tenant-uid>; the intake request does not need that value in the JSON body. The module-instance-id header tells Cisco XDR which Integration Module instance owns the push. XDR resolves that instance and uses it as the Custom Event Source identity.

There are two practical ways to create that relationship:

  1. Use a Custom Security Event Workflow. In XDR Automate, create a workflow with the Custom Security Event intent, choose the security event type and ingestion method, and select or create the target. Cisco’s workflow setup creates the module instance in the background and automatically adds a Custom OCSF Event Source integration. The system exposes the module instance and workflow-run references to the workflow.
  2. Create or select a module instance for a direct integration. In the Integrations area, create a placeholder module instance using the Custom OCSF Event Source module type, or use an existing integration module instance when that is the source identity you intend to represent. Copy its ID into your secure configuration. Give the integration a descriptive display name; that name is what analysts will recognize in source details.

For a script, the header looks like this:

module-instance-id: <your-module-instance-id>

Do not paste a real module ID into source control or a public article. Treat it as a routing identity. If it points at the wrong module instance, the request can authenticate and still appear under the wrong source.

A custom event source is not a replacement for a supported integration. It is the right boundary when you own the source adapter, can maintain its delivery, and need XDR correlation. If Cisco already supports the product and its native integration supplies richer lifecycle state, use that integration instead.

Authentication: omit the scope on purpose

Cisco’s documented script flow is OAuth2 client credentials. Create an API client in Cisco XDR, keep the client ID and password in a secret store, and exchange them for a short-lived access token.

export CUSTOM_SOURCE_CLIENT_ID='<your-client-id>'
export CUSTOM_SOURCE_CLIENT_PASSWORD='<your-client-password>'

curl -sS -X POST \
  -u "$CUSTOM_SOURCE_CLIENT_ID:$CUSTOM_SOURCE_CLIENT_PASSWORD" \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -H 'Accept: application/json' \
  -d 'grant_type=client_credentials' \
  'https://visibility.amp.cisco.com/iroh/oauth2/token'

The response contains an access_token, token type, expiration, and granted scopes. Cisco documents that the token is what belongs in the Authorization: Bearer header and that a new token is needed after expires_in elapses.

The detail that matters for this intake path is what the request does not contain. Do not add scope=integration, scope=admin, or another explicit scope based on the API name. In testing, adding a scope produced the 403 message about an explicit deny in an identity-based policy. Omitting scope produced a usable token and allowed the findings call to reach normal validation.

That does not mean scopes are unimportant across Cisco XDR. Cisco documents scopes as part of its general authorization model. It means this particular token request behaved differently when we constrained the scope. Start with the bare OAuth client token request; only change it if Cisco support or current endpoint documentation gives you a tested, endpoint-specific requirement.

The findings request needs these headers:

Authorization: Bearer <access-token>
Content-Type: application/json
module-instance-id: <your-module-instance-id>

workflow-run-id and x-request-id are optional. A workflow can use its run ID for tracing. A standalone producer should generate an x-request-id and log it with the source event IDs so an API response can be connected to the producer’s record.

Keep credentials out of payloads, finding descriptions, metadata, and logs. The OCSF event should explain the detection, not become another credential store.

The payload is an array of bundles

The direct OCSF endpoint does not accept one event object and it does not accept the simplified detection_findings wrapper used by the separate custom-detection endpoint. Its body is a JSON array. Each array item is a bundle with an events array.

The smallest useful shape is:

[
  {
    "events": [
      {
        "class_uid": 2004,
        "type_uid": 200401,
        "activity_id": 1,
        "category_uid": 2,
        "time": 1760000000000,
        "finding_info": {
          "title": "Suspicious network activity",
          "uid": "<stable-finding-uid>",
          "types": ["Network"]
        },
        "metadata": {
          "uid": "<stable-finding-uid>",
          "product": {
            "uid": "custom-ids",
            "name": "Custom IDS",
            "vendor_name": "Your Security Team"
          },
          "version": "1.0.0"
        }
      }
    ]
  }
]

The timestamp above is illustrative. Generate event time from the source event and convert it to epoch milliseconds. Do not use ingestion time when the source timestamp is available; correlation depends on the event’s actual time.

Do not copy the processed-event wrapper

This is the field trap that made the payload look more reasonable while making the request invalid. Events returned after XDR processing can show fields such as:

product_uid
xdr_tenant_uid
version
time_iso8601

Those are not intake bundle fields. Adding them beside events caused the API to return:

422 unexpected property

The safe intake wrapper is just the bundle’s events array. Do not copy the outer envelope from an event you inspected in XDR back into the POST body. The output representation and the input contract are different.

Keep metadata deliberately small

For intake, keep metadata to these fields:

{
  "metadata": {
    "uid": "<stable-event-uid>",
    "product": {
      "uid": "custom-ids",
      "name": "Custom IDS",
      "vendor_name": "Your Security Team"
    },
    "version": "1.0.0"
  }
}

Adding labels triggered this validation response in testing:

400 metadata.labels must contain exactly one known label

The same applies to the cisco_xdr block and extensions. They are fields we saw on processed XDR events, not fields to add to the source payload. Let XDR add its processing metadata. Your producer supplies the source event’s identity and OCSF content.

Detection Finding is the anchor event

A Detection Finding is the event XDR uses as the bundle’s detection anchor. For the direct endpoint, the baseline fields we use are:

FieldValue or purpose
class_uid2004 for Detection Finding
type_uid200401 for Detection Finding: Create
activity_id1 for Create
category_uid2 for Findings
timeSource event time in epoch milliseconds
finding_info.uidStable source finding identity
finding_info.titleAnalyst-facing detection title
finding_info.typesFinding type, such as Network or Host
metadataOnly uid, product, and version in the intake payload

The API’s validation errors taught us not to stop at the title and UID. Include the class, type, activity, category, time, and finding_info fields on every finding. Add the device, actor, attacks, observables, severity, and description when the source has them.

A network intrusion finding might look like this:

{
  "class_uid": 2004,
  "type_uid": 200401,
  "activity_id": 1,
  "category_uid": 2,
  "time": 1760000000000,
  "severity_id": 4,
  "severity": "High",
  "class_name": "Detection Finding",
  "activity_name": "Create",
  "category_name": "Findings",
  "type_name": "Detection Finding: Create",
  "finding_info": {
    "uid": "<stable-finding-uid>",
    "title": "Outbound connection matched IDS command-and-control rule",
    "desc": "<rule-name> matched <source-ip> to <destination-ip>:<destination-port>",
    "types": ["Network"],
    "created_time": 1760000000000,
    "modified_time": 1760000000000,
    "related_analytics": [
      {
        "type_id": 1,
        "uid": "custom-ids::<rule-id>",
        "name": "<rule-name>",
        "type": "Rule",
        "version": "1"
      }
    ]
  },
  "device": {
    "type_id": 0,
    "type": "Unknown",
    "ip": "<source-ip>",
    "mac": "<source-mac>"
  },
  "observables": [
    {"type_id": 2, "name": "device.ip", "value": "<source-ip>", "type": "IP Address"},
    {"type_id": 3, "name": "device.mac", "value": "<source-mac>", "type": "MAC Address"}
  ],
  "metadata": {
    "uid": "<stable-finding-uid>",
    "product": {"uid": "custom-ids", "name": "Custom IDS", "vendor_name": "Your Security Team"},
    "version": "1.0.0"
  }
}

The finding_info.uid is the important source identity for later correction. A title is for people; the UID is for correlation and update behavior.

Network Activity makes the finding explainable

A Network Activity event uses class_uid 4001. The tested baseline is:

FieldValue or purpose
class_uid4001 for Network Activity
type_uid400100 for Network Activity: Unknown in this model
activity_id0 for Unknown activity in the tested event shape
category_uid4 for Network Activity
timeThe source connection time in epoch milliseconds
src_endpointInternal source address, port, and optional MAC/scope
dst_endpointExternal destination address, port, and scope
connection_infoProtocol and direction details
metadataThe same minimal intake metadata shape

The fields that make a network event useful are not mysterious, but their direction matters. For a detection where an internal host contacts an external command-and-control destination, put the internal address in src_endpoint and the external address in dst_endpoint. That preserves the relationship a downstream workflow or analyst expects.

{
  "class_uid": 4001,
  "type_uid": 400100,
  "activity_id": 0,
  "category_uid": 4,
  "time": 1760000000000,
  "class_name": "Network Activity",
  "activity_name": "Unknown",
  "category_name": "Network Activity",
  "type_name": "Network Activity: Unknown",
  "severity_id": 0,
  "severity": "Unknown",
  "src_endpoint": {
    "ip": "<source-ip>",
    "port": "<source-port>",
    "network_scope_id": 1,
    "network_scope": "Internal",
    "mac": "<source-mac>"
  },
  "dst_endpoint": {
    "ip": "<destination-ip>",
    "port": "<destination-port>",
    "network_scope_id": 2,
    "network_scope": "External"
  },
  "connection_info": {
    "protocol_name": "tcp",
    "protocol_num": 6,
    "direction_id": 0,
    "direction": "Unknown"
  },
  "dispositions": [
    {"disposition": "Detected", "disposition_id": 1}
  ],
  "metadata": {
    "uid": "<stable-network-event-uid>",
    "product": {"uid": "custom-ids", "name": "Custom IDS", "vendor_name": "Your Security Team"},
    "version": "1.0.0"
  }
}

Cisco’s Findings Intake guide documents Network Activity as a supported related event class. Put the activity in finding_info.related_events when it supports the finding, and optionally include it as a full event in the bundle when you want it independently visible. The relationship is what turns a bare alert into a detection with evidence.

A related event can be a compact reference-like object. The tested producer used a type_uid, a stable uid, observables, and type_name inside finding_info.related_events, while the full Network Activity object carried the endpoint and connection fields in the bundle. Keep the same UID relationship in both places.

Worked example: an IDS finding from source to XDR

Suppose an IDS emits one record with a rule ID, source and destination endpoints, protocol, and event time. We want one Detection Finding with one related Network Activity event. We want the finding to land under a custom source and remain updateable if the IDS later corrects its description.

The source record is conceptually:

{
  "event_id": "<source-event-id>",
  "rule_id": "<rule-id>",
  "rule_name": "<rule-name>",
  "src_ip": "<source-ip>",
  "src_port": 52341,
  "src_mac": "<source-mac>",
  "dst_ip": "<destination-ip>",
  "dst_port": 443,
  "protocol": "tcp",
  "event_time_ms": 1760000000000
}

The following code deliberately leaves the tenant and module IDs as environment values. It also uses no OAuth scope and no processed-event wrapper fields.

#!/usr/bin/env python3
import hashlib
import json
import os
import time

import requests

AUTH_URL = "https://visibility.amp.cisco.com/iroh/oauth2/token"
FINDINGS_URL = "https://findings.us.security.cisco.com/api/v1/detection_findings"


def token():
    response = requests.post(
        AUTH_URL,
        auth=(os.environ["CUSTOM_SOURCE_CLIENT_ID"], os.environ["CUSTOM_SOURCE_CLIENT_PASSWORD"]),
        headers={
            "Content-Type": "application/x-www-form-urlencoded",
            "Accept": "application/json",
        },
        data="grant_type=client_credentials",
        timeout=30,
    )
    response.raise_for_status()
    return response.json()["access_token"]


def uid(value):
    return hashlib.sha256(value.encode("utf-8")).hexdigest()


def make_bundle(source):
    finding_uid = uid("custom-ids:finding:" + source["event_id"])
    activity_uid = uid("custom-ids:activity:" + source["event_id"])
    event_time = source["event_time_ms"]

    related_activity = {
        "type_uid": 400100,
        "uid": activity_uid,
        "type_name": "Network Activity: Unknown",
        "observables": [
            {"type_id": 2, "name": "src_endpoint.ip", "value": source["src_ip"], "type": "IP Address"},
            {"type_id": 2, "name": "dst_endpoint.ip", "value": source["dst_ip"], "type": "IP Address"},
        ],
    }

    finding = {
        "class_uid": 2004,
        "type_uid": 200401,
        "activity_id": 1,
        "category_uid": 2,
        "time": event_time,
        "severity_id": 4,
        "severity": "High",
        "class_name": "Detection Finding",
        "activity_name": "Create",
        "category_name": "Findings",
        "type_name": "Detection Finding: Create",
        "finding_info": {
            "uid": finding_uid,
            "title": "<rule-name>",
            "desc": "%s matched %s to %s:%s" % (
                source["rule_name"], source["src_ip"], source["dst_ip"], source["dst_port"]
            ),
            "types": ["Network"],
            "created_time": event_time,
            "modified_time": event_time,
            "related_analytics": [{
                "type_id": 1,
                "uid": "custom-ids::" + source["rule_id"],
                "name": source["rule_name"],
                "type": "Rule",
                "version": "1",
            }],
            "related_events": [related_activity],
        },
        "device": {
            "type_id": 0,
            "type": "Unknown",
            "ip": source["src_ip"],
            "mac": source["src_mac"],
        },
        "observables": [
            {"type_id": 2, "name": "device.ip", "value": source["src_ip"], "type": "IP Address"},
            {"type_id": 3, "name": "device.mac", "value": source["src_mac"], "type": "MAC Address"},
        ],
        "metadata": {
            "uid": finding_uid,
            "product": {"uid": "custom-ids", "name": "Custom IDS", "vendor_name": "Your Security Team"},
            "version": "1.0.0",
        },
    }

    activity = {
        "class_uid": 4001,
        "type_uid": 400100,
        "activity_id": 0,
        "category_uid": 4,
        "time": event_time,
        "class_name": "Network Activity",
        "activity_name": "Unknown",
        "category_name": "Network Activity",
        "type_name": "Network Activity: Unknown",
        "severity_id": 0,
        "severity": "Unknown",
        "src_endpoint": {
            "ip": source["src_ip"],
            "port": source["src_port"],
            "network_scope_id": 1,
            "network_scope": "Internal",
            "mac": source["src_mac"],
        },
        "dst_endpoint": {
            "ip": source["dst_ip"],
            "port": source["dst_port"],
            "network_scope_id": 2,
            "network_scope": "External",
        },
        "connection_info": {
            "protocol_name": source["protocol"],
            "protocol_num": 6,
            "direction_id": 0,
            "direction": "Unknown",
        },
        "dispositions": [{"disposition": "Detected", "disposition_id": 1}],
        "metadata": {
            "uid": activity_uid,
            "product": {"uid": "custom-ids", "name": "Custom IDS", "vendor_name": "Your Security Team"},
            "version": "1.0.0",
        },
    }
    return {"events": [finding, activity]}


def push(bundle):
    response = requests.post(
        FINDINGS_URL,
        headers={
            "Authorization": "Bearer " + token(),
            "Content-Type": "application/json",
            "module-instance-id": os.environ["CUSTOM_SOURCE_MODULE_INSTANCE_ID"],
            "x-request-id": "custom-ids-" + str(int(time.time())),
        },
        json=[bundle],
        timeout=30,
    )
    print(response.status_code, response.text[:1000])
    response.raise_for_status()


source_event = {
    "event_id": "<source-event-id>",
    "rule_id": "<rule-id>",
    "rule_name": "<rule-name>",
    "src_ip": "<source-ip>",
    "src_port": 52341,
    "src_mac": "<source-mac>",
    "dst_ip": "<destination-ip>",
    "dst_port": 443,
    "protocol": "tcp",
    "event_time_ms": int(time.time() * 1000),
}
push(make_bundle(source_event))

The POST sends an array containing one bundle. For a batch, build several bundles and send [bundle_a, bundle_b, ...]; do not wrap that array in {"detection_findings": ...}. A successful response normally means the request was accepted for processing, not that an incident already exists.

After processing, the Detection Findings view should show the finding under the custom source name. Opening it should expose the Detection Finding fields, observables, and related Network Activity. If the correlation engine finds enough relationship with other detections, XDR can associate the event with an incident. A finding with no related incident is still a successful ingestion; correlation and ingestion are separate outcomes.

Verify landing, then verify correlation

A green POST is only the first check. The OCSF bundle endpoint returns 202 Accepted when the service accepts the request for processing. That tells you the transport and initial intake path worked. It does not prove that the finding passed every validation rule, is visible in the UI, or contributed to an incident.

Use a four-part verification record:

CheckWhat to confirm
ProducerSource event ID, stable finding UID, module instance ID reference, request ID, payload hash, and response status
Intake response202, response result/message, any data.version and timestamp, and any per-finding errors
Detection Findings viewSearch the custom source or finding title under Investigate → Detection Findings; narrow the date range to the source event time
Event detail and correlationOpen the result, inspect OCSF fields and related activities, then check related incidents separately

Cisco’s Detection Findings documentation says the table can be searched and filtered by name, source, severity, related incidents, and date range. The table shows a limited view of the newest data, so use a narrow time range and source filter instead of assuming a broad search proves absence.

Record the source-side UID in the finding title or description only as supporting context; search and update behavior should use the structured UID. A finding may be queryable in Detection Findings while still showing no related incident. That is not a failed push. It means correlation has not grouped it with another detection or the event did not meet the tenant’s incident criteria.

For a workflow-driven source, also inspect the Custom Security Event Workflow run. Confirm that its parsing and mapping steps produced the expected event type and that the system-supplied module instance ID is the same source identity used by the direct producer. A source-name mismatch is often an integration configuration problem, not an OCSF problem.

Constraints that change the design

Batch by bundle, not by arbitrary JSON size

The API accepts an array of bundles, and each bundle must contain at least one event. Cisco documents that a native OCSF bundle contains exactly one Detection Finding anchor and may include supported related activity classes through finding_info.related_events or as additional events. Keep one source finding per bundle even when a bundle also contains its supporting Network Activity.

A working proof of concept generated 50 Snort-format bundles and sent them in 10 requests of five bundles each. That is an empirical batching pattern, not a documented maximum. It made failures easy to isolate and kept each request small enough to inspect. Start with a small batch, measure the response, and increase only after you have a retry and reconciliation path.

The OCSF endpoint also documents optional gzip compression. Compression reduces transport size, but it does not change the OCSF contract. Compress the serialized array, set Content-Encoding: gzip, and retain the uncompressed payload hash for audit.

Ordering is a choice, not a promise

The public material used here does not document a required order for events inside events. Our producer puts the Detection Finding first, places its supporting Network Activity in finding_info.related_events, and then includes the full Network Activity event. That makes the relationship explicit and keeps the anchor obvious to a human reading the payload.

Do not rely on array position to identify the result after a retry. Use finding_info.uid, activity UIDs, the source event ID, and the request ID. If your source emits multiple activities for one finding, preserve their stable relationships rather than depending on arrival order.

UIDs are your update key

Cisco’s custom security event workflow guide documents correction by resubmitting the Detection Finding with the same finding_info.uid. The existing finding is updated or overwritten, and downstream correlations are recalculated. If no UID was supplied originally, Cisco can generate one and return it; capture and reuse that value for a later correction.

Generate the UID from a source identity that does not change when the description changes. A source event ID, provider alert ID, or deterministic hash of an immutable source tuple is better than the current title. Do not generate a random UID on every retry, or the retry becomes a second finding.

A correction flow should look like this:

source event <id> arrives
  -> finding_info.uid = stable hash of <id>
  -> POST finding
source corrects the rule description
  -> rebuild the finding with the same finding_info.uid
  -> POST again
  -> verify the updated event and recalculated correlation

The UID behavior is documented for custom security event correction. The handling of every possible duplicate combination across related activity UIDs is not fully described in the public material. Keep activity UIDs stable too, and test corrections in a non-production source before depending on a particular merge result.

Rate limits: use the response, not a guessed number

The public OCSF intake specification documents 429 Too Many Requests and a Retry-After response header. It does not provide a numeric limit in the material used for this post. That is the honest boundary: implement bounded exponential backoff, honor Retry-After, limit concurrent requests, and record which bundles were in each request. Do not publish a made-up requests-per-second number.

A retry policy should distinguish:

  • Connection failure before the request may have been sent: retry the same batch with a bounded attempt count.
  • Timeout after transmission: verify the finding UID before resubmitting.
  • 401: refresh the token once through the authentication path.
  • 403: stop and inspect client authorization and the no-scope rule; repeated calls will not fix permission.
  • 422 or 400 validation: do not retry unchanged; fix the payload.
  • 429: wait for Retry-After and reduce pressure.
  • 5xx: reconcile by UID before repeating because the service may have accepted the request.

The service is still evolving

Cisco’s custom security event workflow documentation identifies the Findings Intake service as beta-era functionality in the documented release context. Treat the public schema and response codes as the contract you can cite, but keep a small compatibility test that sends one known-safe bundle after endpoint or schema changes. Capture the response body and compare the processed event shape in XDR.

A custom source is a commitment

Standing up a custom source is the right call when the source contains detections XDR cannot otherwise see, you can map them to stable OCSF findings, and your team is prepared to operate delivery around the API. That means polling or webhook ingestion, token renewal, backfill after outages, deterministic UIDs, payload validation, rate-aware retries, and a verification view an analyst can actually use.

It is a worse idea when the source already has a supported XDR integration, when you only need to store raw telemetry, or when the team cannot own the source identity after the first demo. Cisco XDR’s custom event path is designed for security events and findings, not as a generic warehouse for every activity emitted by a product. If all you have is noisy raw telemetry, first build detection logic at the source or use a system designed to retain that telemetry.

The strongest custom integrations are intentionally boring. They send a known OCSF bundle to the production endpoint, identify the custom source with the correct module instance, omit fields XDR owns, keep the finding UID stable, and prove the event landed before claiming that correlation worked.

ABOUT THE AUTHOR

Technoxi Security Engineering

Security Automation Team

We connect security telemetry to detection and response systems without losing the identifiers and evidence that make an event useful.