Docs

SIEM Integration

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

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.

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

GEThttps://api.sqd.link/api/v1/instances/:id/audit_events

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

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

afterintegeroptional

Return entries with a sequence greater than this value. This is the cursor a poller advances. Start at 0.

limitintegeroptional

Page size, 11000. Defaults to 100.

orderstringoptional

asc (default, oldest first — what a poller wants) or desc. Any other value returns 400.

beforeintegeroptional

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're for 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": "[email protected]", "via": "dashboard" },
      "target": { "type": "instance_role", "id": 9, "email": "[email protected]" },
      "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_versioninteger

Payload version. Currently 1. Pin your parser to it.

eventsarray

The page of entries, ordered by sequence.

next_afterinteger | null

Highest sequence on this page — pass it back as after to get the next page. null on an empty page.

next_beforeinteger | 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 — and the loop is trivial:

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.

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

GEThttps://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 — a useful alert if a poller silently dies.

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.

Edit this page on GitHubLast updated 2026-09-03