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
Open the Audit Logs page
In the dashboard, go to Audit Logs and click Export tokens in the top-right.
Create the token
Click Create token, give it a name that identifies the consumer (Splunk, Datadog prod), and confirm.
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
https://api.sqd.link/api/v1/instances/:id/audit_eventsFetch a page of audit events for a project, oldest first.
Authenticate with the token as a bearer credential:
curl -H "Authorization: Bearer aet_your_token_here" \
"https://api.sqd.link/api/v1/instances/1395/audit_events?after=0&limit=1000"Query parameters
afterintegeroptionalReturn entries with a sequence greater than this value. This is the cursor a poller advances. Start at 0.
limitintegeroptionalPage size, 1–1000. Defaults to 100.
orderstringoptionalasc (default, oldest first — what a poller wants) or desc. Any other value returns 400.
beforeintegeroptionalReturn 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
{
"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_versionintegerPayload version. Currently 1. Pin your parser to it.
eventsarrayThe page of entries, ordered by sequence.
next_afterinteger | nullHighest sequence on this page — pass it back as after to get the next page. null on an empty page.
next_beforeinteger | nullLowest 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:
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
https://api.sqd.link/api/v1/instances/:id/audit_events/headReturn the latest sequence and hash without fetching entries.
{ "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_atformattedYYYY-MM-DDTHH:MM:SS.ffffffZ— UTC, exactly six fractional digits.actor,target, andchangesserialized with keys sorted recursively. Arrays keep their order.- Compact JSON: no whitespace,
,and:separators, UTF-8 emitted raw rather than\uXXXX,nullfor absent values, integers unquoted. - Values inside
changesare 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:
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.