# SIEM integration

> Stream your Grovs audit log into Splunk, Datadog, or any tool that can poll a JSON endpoint.

Source: https://docs.grovs.io/docs/enterprise/audit-log/siem-integration

The audit log is pulled, not pushed. You create a read-only export token, then poll a single endpoint with a cursor. Anything that can make an authenticated HTTP request on a schedule can ingest it.

## Create an export token

### 1. Open the Audit Logs page

In the dashboard, go to **Audit Logs** and click **Export tokens** in the top-right.

### 2. Create the token

Click **Create token**, give it a name that identifies the consumer (`Splunk`, `Datadog prod`), and confirm.

### 3. Copy it now

The token is shown **once**. Copy it straight into your SIEM's credential store; Grovs cannot show it again.

> **Warning:** Export tokens never expire, because SIEM pollers can't rotate credentials on a schedule. Revoke a token to retire it. Revocation takes effect immediately.

Tokens are read-only: they can read audit events and nothing else. Creating and revoking a token are themselves audit events.

## Pull events

`GET https://api.sqd.link/api/v1/instances/:id/audit_events`

Fetch a page of audit events for a project, oldest first.

`:id` is your numeric instance ID. See [Find the instance ID](https://docs.grovs.io/docs/api-reference/audit-log#find-the-instance-id).

Authenticate with the token as a bearer credential:

```bash
curl -H "Authorization: Bearer aet_your_token_here" \
  "https://api.sqd.link/api/v1/instances/1395/audit_events?after=0&limit=1000"
```

### Query parameters

- `after` (integer, optional): Return entries with a `sequence` **greater than** this value. This is the cursor a poller advances. Start at `0`.
- `limit` (integer, optional): Page size, `1`–`1000`. Defaults to `100`.
- `order` (string, optional): `asc` (default, oldest first, which is what a poller wants) or `desc`. Any other value returns `400`.
- `before` (integer, optional): Return entries with a `sequence` **less than** this value. Used with `order=desc` to page backwards.

`event_action`, `actor_email`, `from` and `to` also exist, but they serve the dashboard's filter UI. A SIEM should page on `after` alone. Filtering server-side risks skipping entries your cursor then jumps past.

### Response

```json
{
  "schema_version": 1,
  "events": [
    {
      "id": 42,
      "sequence": 12,
      "occurred_at": "2026-08-28T10:00:00.123456Z",
      "action": "instance.member_added",
      "outcome": "success",
      "actor": { "type": "user", "id": 3, "email": "admin@acme.com", "via": "dashboard" },
      "target": { "type": "instance_role", "id": 9, "email": "new@acme.com" },
      "changes": { "after": { "role": "member" } },
      "ip": "203.0.113.9",
      "user_agent": "Mozilla/5.0 …",
      "request_id": "b1e5…",
      "prev_hash": "9f2c…",
      "hash": "4a7d…"
    }
  ],
  "next_after": 12,
  "next_before": 12
}
```

- `schema_version` (integer): Payload version. Currently `1`. Pin your parser to it.
- `events` (array): The page of entries, ordered by `sequence`.
- `next_after` (integer | null): Highest `sequence` on this page. Pass it back as `after` to get the next page. `null` on an empty page.
- `next_before` (integer | null): Lowest `sequence` on this page, for paging backwards with `order=desc`.

## The poll loop

Persist one number per project, the last `sequence` you ingested:

```python
cursor = load_cursor()  # 0 on first run

while True:
    r = requests.get(
        f"https://api.sqd.link/api/v1/instances/{INSTANCE_ID}/audit_events",
        headers={"Authorization": f"Bearer {TOKEN}"},
        params={"after": cursor, "limit": 1000},
        timeout=30,
    )
    r.raise_for_status()
    body = r.json()

    if not body["events"]:
        break               # caught up

    for event in body["events"]:
        emit_to_siem(event)

    cursor = body["next_after"]
    save_cursor(cursor)
```

Run it every minute or two. Because `after` is an exclusive cursor over a gap-free counter, this loop never duplicates and never skips, even across restarts, as long as you save the cursor after each page.

> **Warning:** Rate limit: 60 requests per minute per token. With `limit=1000` that's far more headroom than any project needs, but back off if you get a `503`.

## Detecting gaps

`GET https://api.sqd.link/api/v1/instances/:id/audit_events/head`

Return the latest sequence and hash without fetching entries.

```json
{ "schema_version": 1, "sequence": 4182, "hash": "4a7d…" }
```

Compare `sequence` with your stored cursor to see how far behind you are. Alert on it to catch a poller that dies silently.

## Verifying the chain

Each entry stores `prev_hash`, the previous entry's `hash`. To verify independently, recompute:

```
hash = SHA256(JSON(payload))
```

where `payload` is this 13-element array:

```
[schema_version, instance_id, sequence, prev_hash, occurred_at, action,
 outcome, actor, target, changes, ip, user_agent, request_id]
```

Rules that matter for byte-identical output:

- `occurred_at` formatted `YYYY-MM-DDTHH:MM:SS.ffffffZ`: UTC, exactly six fractional digits.
- `actor`, `target`, and `changes` serialized with keys sorted recursively. Arrays keep their order.
- Compact JSON: no whitespace, `,` and `:` separators, UTF-8 emitted raw rather than `\uXXXX`, `null` for absent values, integers unquoted.
- Values inside `changes` are hashed exactly as served. Don't re-parse timestamps or decimals.

Then check that each entry's `prev_hash` equals the previous entry's `hash`. A mismatch means the chain was altered between those two points.

Self-hosted operators can run the built-in checker instead:

```bash
bundle exec rake audit:verify[<instance_id>]
```

It walks the whole chain, compares the last entry against the stored chain head (so trailing deletions are caught too), and exits non-zero on a break.

## A gap in time isn't tampering

If a SaaS enterprise subscription lapses, recording pauses and the endpoints return `403`. On re-subscribe the sequence continues gap-free, so consecutive sequence numbers with a long gap in `occurred_at` mean "not entitled during that window", not "entries removed". The chain stays valid across the pause.
