When the Docs Pointed to the Wrong Endpoint
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:
- 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.
- 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:
| Field | Value or purpose |
|---|---|
class_uid | 2004 for Detection Finding |
type_uid | 200401 for Detection Finding: Create |
activity_id | 1 for Create |
category_uid | 2 for Findings |
time | Source event time in epoch milliseconds |
finding_info.uid | Stable source finding identity |
finding_info.title | Analyst-facing detection title |
finding_info.types | Finding type, such as Network or Host |
metadata | Only 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:
| Field | Value or purpose |
|---|---|
class_uid | 4001 for Network Activity |
type_uid | 400100 for Network Activity: Unknown in this model |
activity_id | 0 for Unknown activity in the tested event shape |
category_uid | 4 for Network Activity |
time | The source connection time in epoch milliseconds |
src_endpoint | Internal source address, port, and optional MAC/scope |
dst_endpoint | External destination address, port, and scope |
connection_info | Protocol and direction details |
metadata | The 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:
| Check | What to confirm |
|---|---|
| Producer | Source event ID, stable finding UID, module instance ID reference, request ID, payload hash, and response status |
| Intake response | 202, response result/message, any data.version and timestamp, and any per-finding errors |
| Detection Findings view | Search the custom source or finding title under Investigate → Detection Findings; narrow the date range to the source event time |
| Event detail and correlation | Open 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.422or400validation: do not retry unchanged; fix the payload.429: wait forRetry-Afterand 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.