> ## Documentation Index
> Fetch the complete documentation index at: https://docs.causeloop.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Data Ingestion API

> Push issues and signals into Causeloop directly — single events, batch records, or source-specific payloads. Includes field reference and code examples.

The Ingestion API lets you push data into Causeloop from any source that doesn't have a managed connector. A single HTTP call turns a raw signal into an Issue that participates in Causeloop's pattern detection.

All ingestion endpoints require a Bearer token with the `ingest:write` scope. See [Authentication](/api-reference/authentication).

## Endpoints at a glance

| Endpoint                          | Use case                                   | Max per call |
| --------------------------------- | ------------------------------------------ | ------------ |
| `POST /v1/ingest/events`          | Single raw signal                          | 1            |
| `POST /v1/ingest/batch`           | Pre-normalized issue records               | 500          |
| `POST /v1/ingest/{source}/issues` | Source-specific push with field validation | 1            |
| `GET /v1/ingest/status/{job_id}`  | Poll async job status                      | —            |

## Single event

Push one raw signal. Causeloop accepts it (`202`) and writes it to the event log; the issue projection and any downstream extraction/clustering are processed asynchronously. Poll `job_id` via `GET /v1/ingest/status/{job_id}` if you need to confirm processing completed.

<CodeGroup>
  ```bash curl theme={null}
  curl -X POST https://api.causeloop.ai/v1/ingest/events \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{
      "source": "custom_api",
      "external_id": "DEPLOY-2026-06-14-001",
      "title": "Deployment failed: payment-service v2.3.1",
      "narrative": "Health check timed out after 30s on 3/5 replicas.",
      "occurred_at": "2026-06-14T14:32:00Z",
      "severity": "p1",
      "external_url": "https://ci.example.com/deploys/12345"
    }'
  ```

  ```javascript JavaScript theme={null}
  const response = await fetch('https://api.causeloop.ai/v1/ingest/events', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.CAUSELOOP_TOKEN}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      source: 'custom_api',
      external_id: 'DEPLOY-2026-06-14-001',
      title: 'Deployment failed: payment-service v2.3.1',
      narrative: 'Health check timed out after 30s on 3/5 replicas.',
      occurred_at: '2026-06-14T14:32:00Z',
      severity: 'p1',
      external_url: 'https://ci.example.com/deploys/12345',
    }),
  });
  const data = await response.json();
  console.log(data.job_id); // ing_job_01hx...
  ```

  ```python Python theme={null}
  import httpx

  resp = httpx.post(
      "https://api.causeloop.ai/v1/ingest/events",
      headers={"Authorization": f"Bearer {TOKEN}"},
      json={
          "source": "custom_api",
          "external_id": "DEPLOY-2026-06-14-001",
          "title": "Deployment failed: payment-service v2.3.1",
          "narrative": "Health check timed out after 30s on 3/5 replicas.",
          "occurred_at": "2026-06-14T14:32:00Z",
          "severity": "p1",
          "external_url": "https://ci.example.com/deploys/12345",
      },
  )
  resp.raise_for_status()
  print(resp.json()["job_id"])  # ing_job_01hx...
  ```
</CodeGroup>

**Response (202 Accepted)**

```json theme={null}
{
  "job_id": "ing_job_01hx5e5f6g",
  "accepted": 1,
  "rejected": [],
  "engine_status_summary": { "blocked_no_narrative": 0 }
}
```

Per-record validation errors are field-level, not all-or-nothing — a rejected record looks like `{"index": 0, "errors": [...]}`.

### Single event fields

<ParamField body="source" type="string" default="custom_api">
  The source system identifier. Use a recognized value for richer pattern matching (see [Source values](#source-values)). Unknown values are coerced to `custom_api`.
</ParamField>

<ParamField body="external_id" type="string" required>
  Your system's unique identifier for this event. Used for deduplication — submitting the same `external_id` + `source` combination twice updates the existing issue rather than creating a duplicate.
</ParamField>

<ParamField body="title" type="string" required>
  Short description of the issue.
</ParamField>

<ParamField body="occurred_at" type="string" required>
  ISO-8601 timestamp of when the finding actually occurred (not when it was logged) — the recurrence/hazard model depends on it. If your connector truly cannot supply this, also send `occurred_at_source: "logged_at"` so the degradation is explicit rather than silently wrong.
</ParamField>

<ParamField body="narrative" type="string">
  Full description or error detail. Can be plain text or Markdown. Formerly named `body`. If `engine_eligible` is true (the default) and `narrative` is omitted, the record is still accepted (`202`) but comes back with `engine_status: "blocked_no_narrative"` and a review item is auto-created — narrative-less issues never enter clustering.
</ParamField>

<ParamField body="severity" type="string" default="p3">
  Priority level: `p1` (critical), `p2` (high), `p3` (medium), `p4` (low).
</ParamField>

<ParamField body="external_url" type="string">
  Link back to the issue in your source system.
</ParamField>

<ParamField body="engine_eligible" type="boolean" default="true">
  Whether this issue should enter the clustering/hazard pipeline at all. Set `false` for signals you want tracked but not analyzed.
</ParamField>

<ParamField body="remediation_history" type="object">
  Optional `{prior_flag, interim_patch, prior_fix_refs[]}` — prior mitigation attempts on this finding.
</ParamField>

<ParamField body="asset_refs" type="string[]">
  Optional references to affected assets.
</ParamField>

<ParamField body="structured_attrs" type="object">
  Optional closed-vocabulary structured attributes (same enums used by AI extraction). Fields populated here with `origin: human` take precedence over anything extraction would infer.
</ParamField>

<ParamField body="metadata" type="object">
  Free-form key/value pairs. Stored as-is and available in the issue detail view.
</ParamField>

## Batch ingest

Push up to 500 pre-normalized issue records in a single call. Each record is processed independently — a failure on one record does not abort the rest.

<CodeGroup>
  ```bash curl theme={null}
  curl -X POST https://api.causeloop.ai/v1/ingest/batch \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{
      "records": [
        {
          "external_id": "JIRA-1234",
          "source": "jira",
          "title": "API gateway returning 503 on /checkout",
          "narrative": "Started after deploy at 14:32 UTC. Affects ~12% of requests.",
          "occurred_at": "2026-06-14T14:32:00Z",
          "severity": "p1",
          "team": "platform",
          "source_created_at": "2026-06-14T14:35:00Z"
        },
        {
          "external_id": "JIRA-1235",
          "source": "jira",
          "title": "Cart service memory leak under sustained load",
          "occurred_at": "2026-06-14T14:38:00Z",
          "severity": "p2",
          "team": "backend",
          "source_created_at": "2026-06-14T14:40:00Z"
        }
      ]
    }'
  ```

  ```javascript JavaScript theme={null}
  const response = await fetch('https://api.causeloop.ai/v1/ingest/batch', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.CAUSELOOP_TOKEN}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      records: [
        {
          external_id: 'JIRA-1234',
          source: 'jira',
          title: 'API gateway returning 503 on /checkout',
          narrative: 'Started after deploy at 14:32 UTC. Affects ~12% of requests.',
          occurred_at: '2026-06-14T14:32:00Z',
          severity: 'p1',
          team: 'platform',
          source_created_at: '2026-06-14T14:35:00Z',
        },
      ],
    }),
  });
  const { accepted, rejected, job_id, engine_status_summary } = await response.json();
  ```

  ```python Python theme={null}
  import httpx

  resp = httpx.post(
      "https://api.causeloop.ai/v1/ingest/batch",
      headers={"Authorization": f"Bearer {TOKEN}"},
      json={
          "records": [
              {
                  "external_id": "JIRA-1234",
                  "source": "jira",
                  "title": "API gateway returning 503 on /checkout",
                  "narrative": "Started after deploy at 14:32 UTC. Affects ~12% of requests.",
                  "occurred_at": "2026-06-14T14:32:00Z",
                  "severity": "p1",
                  "team": "platform",
                  "source_created_at": "2026-06-14T14:35:00Z",
              }
          ]
      },
  )
  resp.raise_for_status()
  result = resp.json()
  print(f"accepted={result['accepted']}, rejected={len(result['rejected'])}")
  ```
</CodeGroup>

**Response (202 Accepted)**

```json theme={null}
{
  "job_id": "ing_job_01hx5e5f7h",
  "accepted": 2,
  "rejected": [],
  "engine_status_summary": { "blocked_no_narrative": 0 }
}
```

If any records fail validation, they appear in `rejected` with an index and field-level errors — the batch is never all-or-nothing:

```json theme={null}
{
  "job_id": "ing_job_01hx5e5f7h",
  "accepted": 1,
  "rejected": [{ "index": 1, "errors": [{ "field": "title", "code": "required" }] }],
  "engine_status_summary": { "blocked_no_narrative": 0 }
}
```

### Batch record fields

<ParamField body="records" type="IngestRecord[]" required>
  Array of records. Between 1 and 500 items.
</ParamField>

Each `IngestRecord`:

<ParamField body="external_id" type="string" required>
  Your system's unique ID. Used for deduplication (with `source`).
</ParamField>

<ParamField body="source" type="string" required>
  Source system identifier. See [Source values](#source-values).
</ParamField>

<ParamField body="title" type="string" required>
  Issue title. 1–300 characters.
</ParamField>

<ParamField body="occurred_at" type="string" required>
  ISO-8601 timestamp of when the finding actually occurred — required; the recurrence/hazard model depends on it. If unavailable, also send `occurred_at_source: "logged_at"`.
</ParamField>

<ParamField body="narrative" type="string">
  Full description. Formerly named `body`. If `engine_eligible` is true (default) and `narrative` is omitted, the record is accepted but flagged `engine_status: "blocked_no_narrative"` and never enters clustering until a narrative is added.
</ParamField>

<ParamField body="severity" type="string" default="p3">
  `p1` | `p2` | `p3` | `p4`
</ParamField>

<ParamField body="team" type="string">
  Team or squad responsible. Used for grouping in the dashboard.
</ParamField>

<ParamField body="external_url" type="string">
  URL back to the source record.
</ParamField>

<ParamField body="source_created_at" type="string">
  ISO 8601 timestamp when the issue was created in the source system.
</ParamField>

<ParamField body="source_updated_at" type="string">
  ISO 8601 timestamp of the last update in the source system.
</ParamField>

<ParamField body="connector_id" type="string">
  Associate this record with an existing connector. Optional.
</ParamField>

## Source-specific ingest

If you know the source system, use the source-specific endpoint for validated field requirements:

```bash theme={null}
curl -X POST https://api.causeloop.ai/v1/ingest/jira/issues \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "external_id": "JIRA-9999",
    "title": "Login failing for SSO users",
    "narrative": "SSO users receive an invalid_grant error from the IdP callback.",
    "occurred_at": "2026-06-14T09:12:00Z",
    "severity": "p1",
    "team": "auth"
  }'
```

Supported source paths: `jira`, `servicenow`, `github`, `pagerduty`, `opsgenie`, `linear`, `zendesk`, `custom_api`.

## Checking job status

Batch and source-specific ingestion is asynchronous. Poll the job endpoint to track progress:

```bash theme={null}
curl https://api.causeloop.ai/v1/ingest/status/ing_job_01hx5e5f6g \
  -H "Authorization: Bearer $TOKEN"
```

```json theme={null}
{
  "job_id": "ing_job_01hx5e5f6g",
  "status": "succeeded",
  "progress": 1.0,
  "result": {
    "accepted": 2,
    "rejected_count": 0,
    "total": 2
  },
  "created_at": "2026-06-14T15:00:00Z",
  "finished_at": "2026-06-14T15:00:03Z"
}
```

Job `status` values: `pending` → `running` → `succeeded` | `partial` | `failed`.

## Source values

Use recognized source values for the best pattern detection. The following values are supported natively:

`jira` · `servicenow` · `github` · `pagerduty` · `opsgenie` · `linear` · `zendesk` · `slack` · `email` · `database` · `custom_api`

Any other value is accepted but coerced to `custom_api`.

## How ingested records become issues

When Causeloop accepts a record:

1. The `external_id` + `source` pair is checked for duplicates. If a matching issue exists, it is updated.
2. The record is normalized: missing fields get defaults, `severity` is validated, and the `source` is resolved.
3. Causeloop writes an `issue.ingested` event to the append-only event log. The issue row itself is a **projection** of that event stream, rebuilt deterministically — later edits (via `PATCH /v1/issues/{id}`) append `field_updated` / `attr_corrected` events rather than mutating history. You can read the ordered event list at `GET /v1/issues/{id}/events`.
4. If `engine_eligible` is true (default) and a `narrative` is present, the issue is picked up by extraction and, on the next engine run, by clustering — pattern membership is a run output, not something computed synchronously per-ingest. Issues without a narrative get `engine_status: "blocked_no_narrative"` and a review item, and never enter clustering until one is added.

<Warning>
  Do not parse the JSON body before computing HMAC signatures for inbound webhooks — always sign the raw bytes. See [Webhooks](/integrations/webhooks) for details.
</Warning>

## Limits

| Limit                 | Value                                 |
| --------------------- | ------------------------------------- |
| Records per batch     | 500                                   |
| `title` max length    | 300 characters                        |
| Request body max size | 5 MB                                  |
| Rate limit            | 1,000 requests / minute per workspace |
