# SIEM export

> Send the evidence ledger to Splunk, Sentinel, Chronicle or any SIEM as OCSF 1.3, or to a tracing backend as OpenTelemetry. Pull it on a schedule, or have it pushed by signed webhook.

Source: https://immiscible.fly.dev/docs/guides/siem-export

The ledger is the source of truth; exports are views of it. Both formats carry the same subject and trace ids the ledger does, so an event in your SIEM can be traced back to the chained, signed record.

## Pull: OCSF

[`GET /api/w/:wid/export/ocsf`](https://immiscible.fly.dev/docs/api/get-api-w-wid-export-ocsf.md) returns NDJSON, one OCSF 1.3 event per line, for readers with evidence rights.

```bash
curl "https://immiscible.fly.dev/api/w/$IMMISCIBLE_WORKSPACE/export/ocsf?since=2026-10-01T00:00:00Z&limit=5000" \
  -H "cookie: __Host-sid=$IMMISCIBLE_SESSION" > immiscible.ndjson
```

| Parameter | Meaning |
|---|---|
| `since`, `until` | ISO 8601 times bounding the records |
| `limit` | how many records at most |

## OpenTelemetry

[`GET /api/w/:wid/export/otel`](https://immiscible.fly.dev/docs/api/get-api-w-wid-export-otel.md) returns OTLP/JSON logs, ready to post to a collector's `/v1/logs`. Records keep their `traceparent`, so they land on the same traces as your own spans.

## Push: signed webhooks

For near real time, register a webhook. Owners and admins create them; the secret is shown once.

curl:

```bash
curl -X POST "https://immiscible.fly.dev/api/w/$IMMISCIBLE_WORKSPACE/webhooks" \
  -H "cookie: __Host-sid=$IMMISCIBLE_SESSION" -H "x-immiscible-csrf: 1" \
  -H "content-type: application/json" \
  -d '{ "url": "https://soar.example.com/hooks/immiscible", "events": ["agent_decision", "agent_freeze", "agent_incident"], "format": "ocsf" }'
```

Response:

```json
{
  "id": "whk_3d2e",
  "url": "https://soar.example.com/hooks/immiscible",
  "events": ["agent_decision", "agent_freeze", "agent_incident"],
  "format": "ocsf",
  "secret": "whsec_...",
  "signature": { "header": "immiscible-signature", "scheme": "t=<unix seconds>,v1=<hex HMAC-SHA256 of \"t.body\">" }
}
```

- `events` is a list of ledger kinds, or `["*"]` for everything. A kind the ledger never writes is refused with `400 invalid_events`, naming the closest real one ("did you mean agent_freeze?"). The kinds an agent's life writes are `agent_decision`, `agent_approval`, `agent_freeze`, `agent_incident`, `agent_settlement` and `mandate_change`.
- `format` is `ocsf` (the default) or `native` (the ledger record as it stands).
- Up to five attempts with backoff. Private and internal addresses are refused.
- [`POST .../webhooks/:id/test`](https://immiscible.fly.dev/docs/api/post-api-w-wid-webhooks-id-test.md) sends one now; [`GET .../deliveries`](https://immiscible.fly.dev/docs/api/get-api-w-wid-webhooks-id-deliveries.md) shows what was sent (a SHA-256 of the body) and what came back (the status and the first 1 KB), and [`POST .../deliveries/:did/resend`](https://immiscible.fly.dev/docs/api/post-api-w-wid-webhooks-id-deliveries-did-resend.md) sends one again.

## Verifying a delivery

Each delivery carries `immiscible-signature: t=<unix>,v1=<hex>`, an HMAC-SHA256 of `<t>.<raw body>` with the webhook's secret. Check it before trusting the body, and refuse old timestamps:

Node:

```ts
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifyImmiscible(rawBody: string, header: string, secret: string, toleranceSec = 300): boolean {
  const parts = Object.fromEntries(header.split(',').map((kv) => kv.split('=', 2)));
  const t = Number(parts.t);
  if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
  const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
  const given = Buffer.from(parts.v1 ?? '', 'hex');
  return given.length === expected.length && timingSafeEqual(given, expected);
}
```

Python:

```python
import hashlib
import hmac
import time

def verify_immiscible(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(kv.split("=", 1) for kv in header.split(","))
    t = int(parts.get("t", "0"))
    if abs(time.time() - t) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))
```

## From a SOAR

A playbook that reads events from your SIEM usually wants to act too: freeze an agent, list waiting approvals, pull the evidence bundle. Give it a [service token](https://immiscible.fly.dev/docs/api/authentication.md#service-tokens) with exactly the scopes it needs, and read [the kill switch](https://immiscible.fly.dev/docs/guides/kill-switch.md#freeze-a-fleet) for the safe way to freeze many agents.
